diff --git a/api/api/auth/login/index.ts b/api/api/auth/login/index.ts
index a9352ecf..b7ed340d 100644
--- a/api/api/auth/login/index.ts
+++ b/api/api/auth/login/index.ts
@@ -1,80 +1,16 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, handleZodError } from "@/api";
import { Application, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { eq, or } from "drizzle-orm";
import type { Context } from "hono";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
import { setCookie } from "hono/cookie";
import { SignJWT } from "jose";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
-const schemas = {
- form: z.object({
- identifier: z
- .string()
- .email()
- .toLowerCase()
- .or(z.string().toLowerCase()),
- password: z.string().min(2).max(100),
- }),
- query: z.object({
- scope: z.string().optional(),
- redirect_uri: z.string().url().optional(),
- response_type: z.enum([
- "code",
- "token",
- "none",
- "id_token",
- "code id_token",
- "code token",
- "token id_token",
- "code token id_token",
- ]),
- client_id: z.string(),
- state: z.string().optional(),
- code_challenge: z.string().optional(),
- code_challenge_method: z.enum(["plain", "S256"]).optional(),
- prompt: z
- .enum(["none", "login", "consent", "select_account"])
- .optional()
- .default("none"),
- max_age: z
- .number()
- .int()
- .optional()
- .default(60 * 60 * 24 * 7),
- }),
-};
-
-const route = createRoute({
- method: "post",
- path: "/api/auth/login",
- summary: "Login",
- description: "Login to the application",
- request: {
- body: {
- content: {
- "multipart/form-data": {
- schema: schemas.form,
- },
- },
- },
- query: schemas.query,
- },
- responses: {
- 302: {
- description: "Redirect to OAuth authorize, or error",
- headers: {
- "Set-Cookie": {
- description: "JWT cookie",
- required: false,
- },
- },
- },
- },
-});
-
const returnError = (
context: Context,
error: string,
@@ -101,126 +37,194 @@ const returnError = (
};
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const oidcConfig = config.plugins?.config?.["@versia/openid"] as
- | {
- forced: boolean;
- providers: {
- id: string;
- name: string;
- icon: string;
- }[];
- keys: {
- private: string;
- public: string;
- };
- }
- | undefined;
-
- if (!oidcConfig) {
- return returnError(
- context,
- "invalid_request",
- "The OpenID Connect plugin is not enabled on this instance. Cannot process login request.",
- );
- }
-
- if (oidcConfig?.forced) {
- return returnError(
- context,
- "invalid_request",
- "Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
- );
- }
-
- const { identifier, password } = context.req.valid("form");
- const { client_id } = context.req.valid("query");
-
- // Find user
- const user = await User.fromSql(
- or(
- eq(Users.email, identifier.toLowerCase()),
- eq(Users.username, identifier.toLowerCase()),
- ),
- );
-
- if (
- !(
- user &&
- (await Bun.password.verify(password, user.data.password || ""))
- )
- ) {
- return returnError(
- context,
- "invalid_grant",
- "Invalid identifier or password",
- );
- }
-
- if (user.data.passwordResetToken) {
- return context.redirect(
- `${config.frontend.routes.password_reset}?${new URLSearchParams(
- {
- token: user.data.passwordResetToken ?? "",
- login_reset: "true",
+ app.post(
+ "/api/auth/login",
+ describeRoute({
+ summary: "Login",
+ description: "Login to the application",
+ responses: {
+ 302: {
+ description: "Redirect to OAuth authorize, or error",
+ headers: {
+ "Set-Cookie": {
+ description: "JWT cookie",
+ required: false,
+ },
},
- ).toString()}`,
- );
- }
+ },
+ },
+ }),
+ validator(
+ "query",
+ z.object({
+ scope: z.string().optional(),
+ redirect_uri: z.string().url().optional(),
+ response_type: z.enum([
+ "code",
+ "token",
+ "none",
+ "id_token",
+ "code id_token",
+ "code token",
+ "token id_token",
+ "code token id_token",
+ ]),
+ client_id: z.string(),
+ state: z.string().optional(),
+ code_challenge: z.string().optional(),
+ code_challenge_method: z.enum(["plain", "S256"]).optional(),
+ prompt: z
+ .enum(["none", "login", "consent", "select_account"])
+ .optional()
+ .default("none"),
+ max_age: z
+ .number()
+ .int()
+ .optional()
+ .default(60 * 60 * 24 * 7),
+ }),
+ handleZodError,
+ ),
+ validator(
+ "form",
+ z.object({
+ identifier: z
+ .string()
+ .email()
+ .toLowerCase()
+ .or(z.string().toLowerCase()),
+ password: z.string().min(2).max(100),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const oidcConfig = config.plugins?.config?.["@versia/openid"] as
+ | {
+ forced: boolean;
+ providers: {
+ id: string;
+ name: string;
+ icon: string;
+ }[];
+ keys: {
+ private: string;
+ public: string;
+ };
+ }
+ | undefined;
- // Try and import the key
- const privateKey = await crypto.subtle.importKey(
- "pkcs8",
- Buffer.from(oidcConfig?.keys?.private ?? "", "base64"),
- "Ed25519",
- false,
- ["sign"],
- );
-
- // Generate JWT
- const jwt = await new SignJWT({
- sub: user.id,
- iss: config.http.base_url.origin,
- aud: client_id,
- exp: Math.floor(Date.now() / 1000) + 60 * 60,
- iat: Math.floor(Date.now() / 1000),
- nbf: Math.floor(Date.now() / 1000),
- })
- .setProtectedHeader({ alg: "EdDSA" })
- .sign(privateKey);
-
- const application = await Application.fromClientId(client_id);
-
- if (!application) {
- throw new ApiError(400, "Invalid application");
- }
-
- const searchParams = new URLSearchParams({
- application: application.data.name,
- });
-
- if (application.data.website) {
- searchParams.append("website", application.data.website);
- }
-
- // Add all data that is not undefined except email and password
- for (const [key, value] of Object.entries(context.req.query())) {
- if (key !== "email" && key !== "password" && value !== undefined) {
- searchParams.append(key, String(value));
+ if (!oidcConfig) {
+ return returnError(
+ context,
+ "invalid_request",
+ "The OpenID Connect plugin is not enabled on this instance. Cannot process login request.",
+ );
}
- }
- // Redirect to OAuth authorize with JWT
- setCookie(context, "jwt", jwt, {
- httpOnly: true,
- secure: true,
- sameSite: "Strict",
- path: "/",
- // 2 weeks
- maxAge: 60 * 60 * 24 * 14,
- });
- return context.redirect(
- `${config.frontend.routes.consent}?${searchParams.toString()}`,
- );
- }),
+ if (oidcConfig?.forced) {
+ return returnError(
+ context,
+ "invalid_request",
+ "Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
+ );
+ }
+
+ const { identifier, password } = context.req.valid("form");
+ const { client_id } = context.req.valid("query");
+
+ // Find user
+ const user = await User.fromSql(
+ or(
+ eq(Users.email, identifier.toLowerCase()),
+ eq(Users.username, identifier.toLowerCase()),
+ ),
+ );
+
+ if (
+ !(
+ user &&
+ (await Bun.password.verify(
+ password,
+ user.data.password || "",
+ ))
+ )
+ ) {
+ return returnError(
+ context,
+ "invalid_grant",
+ "Invalid identifier or password",
+ );
+ }
+
+ if (user.data.passwordResetToken) {
+ return context.redirect(
+ `${config.frontend.routes.password_reset}?${new URLSearchParams(
+ {
+ token: user.data.passwordResetToken ?? "",
+ login_reset: "true",
+ },
+ ).toString()}`,
+ );
+ }
+
+ // Try and import the key
+ const privateKey = await crypto.subtle.importKey(
+ "pkcs8",
+ Buffer.from(oidcConfig?.keys?.private ?? "", "base64"),
+ "Ed25519",
+ false,
+ ["sign"],
+ );
+
+ // Generate JWT
+ const jwt = await new SignJWT({
+ sub: user.id,
+ iss: config.http.base_url.origin,
+ aud: client_id,
+ exp: Math.floor(Date.now() / 1000) + 60 * 60,
+ iat: Math.floor(Date.now() / 1000),
+ nbf: Math.floor(Date.now() / 1000),
+ })
+ .setProtectedHeader({ alg: "EdDSA" })
+ .sign(privateKey);
+
+ const application = await Application.fromClientId(client_id);
+
+ if (!application) {
+ throw new ApiError(400, "Invalid application");
+ }
+
+ const searchParams = new URLSearchParams({
+ application: application.data.name,
+ });
+
+ if (application.data.website) {
+ searchParams.append("website", application.data.website);
+ }
+
+ // Add all data that is not undefined except email and password
+ for (const [key, value] of Object.entries(context.req.query())) {
+ if (
+ key !== "email" &&
+ key !== "password" &&
+ value !== undefined
+ ) {
+ searchParams.append(key, String(value));
+ }
+ }
+
+ // Redirect to OAuth authorize with JWT
+ setCookie(context, "jwt", jwt, {
+ httpOnly: true,
+ secure: true,
+ sameSite: "Strict",
+ path: "/",
+ // 2 weeks
+ maxAge: 60 * 60 * 24 * 14,
+ });
+ return context.redirect(
+ `${config.frontend.routes.consent}?${searchParams.toString()}`,
+ );
+ },
+ ),
);
diff --git a/api/api/auth/redirect/index.ts b/api/api/auth/redirect/index.ts
index 34aa7f68..5d912d2a 100644
--- a/api/api/auth/redirect/index.ts
+++ b/api/api/auth/redirect/index.ts
@@ -1,72 +1,76 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, handleZodError } from "@/api";
import { db } from "@versia/kit/db";
import { Applications, Tokens } from "@versia/kit/tables";
import { and, eq } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
+import { z } from "zod";
import { config } from "~/config.ts";
-const schemas = {
- query: z.object({
- redirect_uri: z.string().url(),
- client_id: z.string(),
- code: z.string(),
- }),
-};
-
-const route = createRoute({
- method: "get",
- path: "/api/auth/redirect",
- summary: "OAuth Code flow",
- description:
- "Redirects to the application, or back to login if the code is invalid",
- tags: ["OpenID"],
- responses: {
- 302: {
- description:
- "Redirects to the application, or back to login if the code is invalid",
- },
- },
- request: {
- query: schemas.query,
- },
-});
-
/**
* OAuth Code flow
*/
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { redirect_uri, client_id, code } = context.req.valid("query");
+ app.get(
+ "/api/auth/redirect",
+ describeRoute({
+ summary: "OAuth Code flow",
+ description:
+ "Redirects to the application, or back to login if the code is invalid",
+ tags: ["OpenID"],
+ responses: {
+ 302: {
+ description:
+ "Redirects to the application, or back to login if the code is invalid",
+ },
+ },
+ }),
+ validator(
+ "query",
+ z.object({
+ redirect_uri: z.string().url(),
+ client_id: z.string(),
+ code: z.string(),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { redirect_uri, client_id, code } =
+ context.req.valid("query");
- const redirectToLogin = (error: string): Response =>
- context.redirect(
- `${config.frontend.routes.login}?${new URLSearchParams({
- ...context.req.query,
- error: encodeURIComponent(error),
+ const redirectToLogin = (error: string): Response =>
+ context.redirect(
+ `${config.frontend.routes.login}?${new URLSearchParams({
+ ...context.req.query,
+ error: encodeURIComponent(error),
+ }).toString()}`,
+ );
+
+ const foundToken = await db
+ .select()
+ .from(Tokens)
+ .leftJoin(
+ Applications,
+ eq(Tokens.applicationId, Applications.id),
+ )
+ .where(
+ and(
+ eq(Tokens.code, code),
+ eq(Applications.clientId, client_id),
+ ),
+ )
+ .limit(1);
+
+ if (!foundToken || foundToken.length <= 0) {
+ return redirectToLogin("Invalid code");
+ }
+
+ // Redirect back to application
+ return context.redirect(
+ `${redirect_uri}?${new URLSearchParams({
+ code,
}).toString()}`,
);
-
- const foundToken = await db
- .select()
- .from(Tokens)
- .leftJoin(Applications, eq(Tokens.applicationId, Applications.id))
- .where(
- and(
- eq(Tokens.code, code),
- eq(Applications.clientId, client_id),
- ),
- )
- .limit(1);
-
- if (!foundToken || foundToken.length <= 0) {
- return redirectToLogin("Invalid code");
- }
-
- // Redirect back to application
- return context.redirect(
- `${redirect_uri}?${new URLSearchParams({
- code,
- }).toString()}`,
- );
- }),
+ },
+ ),
);
diff --git a/api/api/auth/reset/index.ts b/api/api/auth/reset/index.ts
index 96cb5a43..58f33312 100644
--- a/api/api/auth/reset/index.ts
+++ b/api/api/auth/reset/index.ts
@@ -1,42 +1,13 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, handleZodError } from "@/api";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import type { Context } from "hono";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
+import { z } from "zod";
import { config } from "~/config.ts";
-const schemas = {
- form: z.object({
- token: z.string().min(1),
- password: z.string().min(3).max(100),
- }),
-};
-
-const route = createRoute({
- method: "post",
- path: "/api/auth/reset",
- summary: "Reset password",
- description: "Reset password",
- responses: {
- 302: {
- description: "Redirect to the password reset page with a message",
- },
- },
- request: {
- body: {
- content: {
- "application/x-www-form-urlencoded": {
- schema: schemas.form,
- },
- "multipart/form-data": {
- schema: schemas.form,
- },
- },
- },
- },
-});
-
const returnError = (
context: Context,
token: string,
@@ -60,27 +31,50 @@ const returnError = (
};
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { token, password } = context.req.valid("form");
+ app.post(
+ "/api/auth/reset",
+ describeRoute({
+ summary: "Reset password",
+ description: "Reset password",
+ responses: {
+ 302: {
+ description:
+ "Redirect to the password reset page with a message",
+ },
+ },
+ }),
+ validator(
+ "form",
+ z.object({
+ token: z.string().min(1),
+ password: z.string().min(3).max(100),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { token, password } = context.req.valid("form");
- const user = await User.fromSql(eq(Users.passwordResetToken, token));
-
- if (!user) {
- return returnError(
- context,
- token,
- "invalid_token",
- "Invalid token",
+ const user = await User.fromSql(
+ eq(Users.passwordResetToken, token),
);
- }
- await user.update({
- password: await Bun.password.hash(password),
- passwordResetToken: null,
- });
+ if (!user) {
+ return returnError(
+ context,
+ token,
+ "invalid_token",
+ "Invalid token",
+ );
+ }
- return context.redirect(
- `${config.frontend.routes.password_reset}?success=true`,
- );
- }),
+ await user.update({
+ password: await Bun.password.hash(password),
+ passwordResetToken: null,
+ });
+
+ return context.redirect(
+ `${config.frontend.routes.password_reset}?success=true`,
+ );
+ },
+ ),
);
diff --git a/api/api/v1/accounts/:id/block.ts b/api/api/v1/accounts/:id/block.ts
deleted file mode 100644
index 6634b02a..00000000
--- a/api/api/v1/accounts/:id/block.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/block",
- summary: "Block account",
- description:
- "Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#block",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:blocks"],
- permissions: [
- RolePermission.ManageOwnBlocks,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- responses: {
- 200: {
- description:
- "Successfully blocked, or account was already blocked.",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const otherUser = context.get("user");
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- if (!foundRelationship.data.blocking) {
- await foundRelationship.update({
- blocking: true,
- });
- }
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/follow.ts b/api/api/v1/accounts/:id/follow.ts
deleted file mode 100644
index 084bc174..00000000
--- a/api/api/v1/accounts/:id/follow.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
- iso631,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/follow",
- summary: "Follow account",
- description:
- "Follow the given account. Can also be used to update whether to show reblogs or enable notifications.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#follow",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:follows"],
- permissions: [
- RolePermission.ManageOwnFollows,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- responses: {
- 200: {
- description:
- "Successfully followed, or account was already followed",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 403: {
- description:
- "Trying to follow someone that you block or that blocks you",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- body: {
- content: {
- "application/json": {
- schema: z.object({
- reblogs: z.boolean().default(true).openapi({
- description:
- "Receive this account’s reblogs in home timeline?",
- example: true,
- }),
- notify: z.boolean().default(false).openapi({
- description:
- "Receive notifications when this account posts a status?",
- example: false,
- }),
- languages: z
- .array(iso631)
- .default([])
- .openapi({
- description:
- "Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.",
- example: ["en", "fr"],
- }),
- }),
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const { reblogs, notify, languages } = context.req.valid("json");
- const otherUser = context.get("user");
-
- let relationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- if (!relationship.data.following) {
- relationship = await user.followRequest(otherUser, {
- reblogs,
- notify,
- languages,
- });
- }
-
- return context.json(relationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/followers.ts b/api/api/v1/accounts/:id/followers.ts
deleted file mode 100644
index b1ea3677..00000000
--- a/api/api/v1/accounts/:id/followers.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Account as AccountSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Timeline } from "@versia/kit/db";
-import { Users } from "@versia/kit/tables";
-import { and, gt, gte, lt, sql } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/accounts/{id}/followers",
- summary: "Get account’s followers",
- description:
- "Accounts which follow the given account, if network is not hidden by the account owner.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#followers",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: false,
- scopes: ["read:accounts"],
- permissions: [
- RolePermission.ViewAccountFollows,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- query: z.object({
- max_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: AccountSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.number().int().min(1).max(40).default(20).openapi({
- description: "Maximum number of results to return.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "Accounts which follow the given account.",
- content: {
- "application/json": {
- schema: z.array(AccountSchema),
- },
- },
- headers: z.object({
- link: z
- .string()
- .optional()
- .openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
- }),
- }),
- },
- 404: ApiError.accountNotFound().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, since_id, min_id, limit } = context.req.valid("query");
- const otherUser = context.get("user");
-
- // TODO: Add follower/following privacy settings
- const { objects, link } = await Timeline.getUserTimeline(
- and(
- max_id ? lt(Users.id, max_id) : undefined,
- since_id ? gte(Users.id, since_id) : undefined,
- min_id ? gt(Users.id, min_id) : undefined,
- sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
- ),
- limit,
- new URL(context.req.url),
- );
-
- return context.json(
- await Promise.all(objects.map((object) => object.toApi())),
- 200,
- {
- Link: link,
- },
- );
- }),
-);
diff --git a/api/api/v1/accounts/:id/following.ts b/api/api/v1/accounts/:id/following.ts
deleted file mode 100644
index 36739c99..00000000
--- a/api/api/v1/accounts/:id/following.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Account as AccountSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Timeline } from "@versia/kit/db";
-import { Users } from "@versia/kit/tables";
-import { and, gt, gte, lt, sql } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/accounts/{id}/following",
- summary: "Get account’s following",
- description:
- "Accounts which the given account is following, if network is not hidden by the account owner.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#following",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: false,
- scopes: ["read:accounts"],
- permissions: [
- RolePermission.ViewAccountFollows,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- query: z.object({
- max_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: AccountSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.number().int().min(1).max(40).default(20).openapi({
- description: "Maximum number of results to return.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "Accounts which the given account is following.",
- content: {
- "application/json": {
- schema: z.array(AccountSchema),
- },
- },
- headers: z.object({
- link: z
- .string()
- .optional()
- .openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
- }),
- }),
- },
- 404: ApiError.accountNotFound().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, since_id, min_id } = context.req.valid("query");
- const otherUser = context.get("user");
-
- // TODO: Add follower/following privacy settings
-
- const { objects, link } = await Timeline.getUserTimeline(
- and(
- max_id ? lt(Users.id, max_id) : undefined,
- since_id ? gte(Users.id, since_id) : undefined,
- min_id ? gt(Users.id, min_id) : undefined,
- sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
- ),
- context.req.valid("query").limit,
- new URL(context.req.url),
- );
-
- return context.json(
- await Promise.all(objects.map((object) => object.toApi())),
- 200,
- {
- Link: link,
- },
- );
- }),
-);
diff --git a/api/api/v1/accounts/:id/index.ts b/api/api/v1/accounts/:id/index.ts
deleted file mode 100644
index 222d4c93..00000000
--- a/api/api/v1/accounts/:id/index.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Account as AccountSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/accounts/{id}",
- summary: "Get account",
- description: "View information about a profile.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#get",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: false,
- permissions: [RolePermission.ViewAccounts],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description:
- "The Account record will be returned. Note that acct of local users does not include the domain name.",
- content: {
- "application/json": {
- schema: AccountSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, (context) => {
- const { user } = context.get("auth");
- const otherUser = context.get("user");
-
- return context.json(otherUser.toApi(user?.id === otherUser.id), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/mute.ts b/api/api/v1/accounts/:id/mute.ts
deleted file mode 100644
index 18db3c62..00000000
--- a/api/api/v1/accounts/:id/mute.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/mute",
- summary: "Mute account",
- description:
- "Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#mute",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:mutes"],
- permissions: [
- RolePermission.ManageOwnMutes,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- body: {
- content: {
- "application/json": {
- schema: z.object({
- notifications: z.boolean().default(true).openapi({
- description:
- "Mute notifications in addition to statuses?",
- }),
- duration: z
- .number()
- .int()
- .min(0)
- .max(60 * 60 * 24 * 365 * 5)
- .default(0)
- .openapi({
- description:
- "How long the mute should last, in seconds.",
- }),
- }),
- },
- },
- },
- },
- responses: {
- 200: {
- description:
- "Successfully muted, or account was already muted. Note that you can call this API method again with notifications=false to update the relationship so that only statuses are muted.",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- // TODO: Add duration support
- const { notifications } = context.req.valid("json");
- const otherUser = context.get("user");
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- // TODO: Implement duration
- await foundRelationship.update({
- muting: true,
- mutingNotifications: notifications,
- });
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/note.ts b/api/api/v1/accounts/:id/note.ts
deleted file mode 100644
index 79621cd5..00000000
--- a/api/api/v1/accounts/:id/note.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/note",
- summary: "Set private note on profile",
- description: "Sets a private note on a user.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#note",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:accounts"],
- permissions: [
- RolePermission.ManageOwnAccount,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- body: {
- content: {
- "application/json": {
- schema: z.object({
- comment: RelationshipSchema.shape.note
- .optional()
- .openapi({
- description:
- "The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
- }),
- }),
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Successfully updated profile note",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const { comment } = context.req.valid("json");
- const otherUser = context.get("user");
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- await foundRelationship.update({
- note: comment ?? "",
- });
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/pin.ts b/api/api/v1/accounts/:id/pin.ts
deleted file mode 100644
index f9395c4d..00000000
--- a/api/api/v1/accounts/:id/pin.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/pin",
- summary: "Feature account on your profile",
- description:
- "Add the given account to the user’s featured profiles. (Featured profiles are currently shown on the user’s own public profile.)",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#pin",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:accounts"],
- permissions: [
- RolePermission.ManageOwnAccount,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Updated relationship",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const otherUser = context.get("user");
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- await foundRelationship.update({
- endorsed: true,
- });
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/refetch.ts b/api/api/v1/accounts/:id/refetch.ts
deleted file mode 100644
index 662c9d9e..00000000
--- a/api/api/v1/accounts/:id/refetch.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Account as AccountSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/refetch",
- summary: "Refetch account",
- description: "Refetch the given account's profile from the remote server",
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:accounts"],
- permissions: [RolePermission.ViewAccounts],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Refetched account data",
- content: {
- "application/json": {
- schema: AccountSchema,
- },
- },
- },
- 400: {
- description: "User is local",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const otherUser = context.get("user");
-
- if (otherUser.isLocal()) {
- throw new ApiError(400, "Cannot refetch a local user");
- }
-
- const newUser = await otherUser.updateFromRemote();
-
- return context.json(newUser.toApi(false), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/remove_from_followers.ts b/api/api/v1/accounts/:id/remove_from_followers.ts
deleted file mode 100644
index 99bae5ba..00000000
--- a/api/api/v1/accounts/:id/remove_from_followers.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/remove_from_followers",
- summary: "Remove account from followers",
- description: "Remove the given account from your followers.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#remove_from_followers",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:follows"],
- permissions: [
- RolePermission.ManageOwnFollows,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description:
- "Successfully removed from followers, or account was already not following you",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const otherUser = context.get("user");
-
- const oppositeRelationship = await Relationship.fromOwnerAndSubject(
- otherUser,
- user,
- );
-
- if (oppositeRelationship.data.following) {
- await oppositeRelationship.update({
- following: false,
- });
- }
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/roles/:role_id/index.ts b/api/api/v1/accounts/:id/roles/:role_id/index.ts
deleted file mode 100644
index d1caa093..00000000
--- a/api/api/v1/accounts/:id/roles/:role_id/index.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Role as RoleSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Role } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const routePost = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/roles/{role_id}",
- summary: "Assign role to account",
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageRoles],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- role_id: RoleSchema.shape.id,
- }),
- },
- responses: {
- 204: {
- description: "Role assigned",
- },
- 404: ApiError.roleNotFound().schema,
- 403: ApiError.forbidden().schema,
- },
-});
-
-const routeDelete = createRoute({
- method: "delete",
- path: "/api/v1/accounts/{id}/roles/{role_id}",
- summary: "Remove role from user",
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageRoles],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- role_id: RoleSchema.shape.id,
- }),
- },
- responses: {
- 204: {
- description: "Role removed",
- },
- 404: ApiError.roleNotFound().schema,
- 403: ApiError.forbidden().schema,
- },
-});
-
-export default apiRoute((app) => {
- app.openapi(routePost, async (context) => {
- const { user } = context.get("auth");
- const { role_id } = context.req.valid("param");
- const targetUser = context.get("user");
-
- const role = await Role.fromId(role_id);
-
- if (!role) {
- throw ApiError.roleNotFound();
- }
- // Priority check
- const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
-
- const userHighestRole = userRoles.reduce((prev, current) =>
- prev.data.priority > current.data.priority ? prev : current,
- );
-
- if (role.data.priority > userHighestRole.data.priority) {
- throw new ApiError(
- 403,
- "Forbidden",
- `User with highest role priority ${userHighestRole.data.priority} cannot assign role with priority ${role.data.priority}`,
- );
- }
-
- await role.linkUser(targetUser.id);
-
- return context.body(null, 204);
- });
-
- app.openapi(routeDelete, async (context) => {
- const { user } = context.get("auth");
- const { role_id } = context.req.valid("param");
- const targetUser = context.get("user");
-
- const role = await Role.fromId(role_id);
-
- if (!role) {
- throw ApiError.roleNotFound();
- }
-
- // Priority check
- const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
-
- const userHighestRole = userRoles.reduce((prev, current) =>
- prev.data.priority > current.data.priority ? prev : current,
- );
-
- if (role.data.priority > userHighestRole.data.priority) {
- throw new ApiError(
- 403,
- "Forbidden",
- `User with highest role priority ${userHighestRole.data.priority} cannot remove role with priority ${role.data.priority}`,
- );
- }
-
- await role.unlinkUser(targetUser.id);
-
- return context.body(null, 204);
- });
-});
diff --git a/api/api/v1/accounts/:id/roles/index.ts b/api/api/v1/accounts/:id/roles/index.ts
deleted file mode 100644
index dd7f169c..00000000
--- a/api/api/v1/accounts/:id/roles/index.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Role as RoleSchema,
-} from "@versia/client/schemas";
-import { Role } from "@versia/kit/db";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/accounts/{id}/roles",
- summary: "List account roles",
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: false,
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "List of roles",
- content: {
- "application/json": {
- schema: z.array(RoleSchema),
- },
- },
- },
- },
-});
-
-export default apiRoute((app) => {
- app.openapi(route, async (context) => {
- const targetUser = context.get("user");
-
- const roles = await Role.getUserRoles(
- targetUser.id,
- targetUser.data.isAdmin,
- );
-
- return context.json(
- roles.map((role) => role.toApi()),
- 200,
- );
- });
-});
diff --git a/api/api/v1/accounts/:id/statuses.ts b/api/api/v1/accounts/:id/statuses.ts
deleted file mode 100644
index e1ee1ffc..00000000
--- a/api/api/v1/accounts/:id/statuses.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Status as StatusSchema,
- zBoolean,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Timeline } from "@versia/kit/db";
-import { Notes } from "@versia/kit/tables";
-import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/accounts/{id}/statuses",
- summary: "Get account’s statuses",
- description: "Statuses posted to the given account.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#statuses",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: false,
- permissions: [
- RolePermission.ViewNotes,
- RolePermission.ViewAccounts,
- ],
- scopes: ["read:statuses"],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- query: z.object({
- max_id: StatusSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: StatusSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: StatusSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce.number().int().min(1).max(40).default(20).openapi({
- description: "Maximum number of results to return.",
- }),
- only_media: zBoolean.default(false).openapi({
- description: "Filter out statuses without attachments.",
- }),
- exclude_replies: zBoolean.default(false).openapi({
- description:
- "Filter out statuses in reply to a different account.",
- }),
- exclude_reblogs: zBoolean.default(false).openapi({
- description: "Filter out boosts from the response.",
- }),
- pinned: zBoolean.default(false).openapi({
- description:
- "Filter for pinned statuses only. Pinned statuses do not receive special priority in the order of the returned results.",
- }),
- tagged: z.string().optional().openapi({
- description: "Filter for statuses using a specific hashtag.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "Statuses posted to the given account.",
- content: {
- "application/json": {
- schema: z.array(StatusSchema),
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const otherUser = context.get("user");
-
- const {
- max_id,
- min_id,
- since_id,
- limit,
- exclude_reblogs,
- only_media,
- exclude_replies,
- pinned,
- } = context.req.valid("query");
-
- const { objects } = await Timeline.getNoteTimeline(
- and(
- max_id ? lt(Notes.id, max_id) : undefined,
- since_id ? gte(Notes.id, since_id) : undefined,
- min_id ? gt(Notes.id, min_id) : undefined,
- eq(Notes.authorId, otherUser.id),
- only_media
- ? sql`EXISTS (SELECT 1 FROM "Medias" WHERE "Medias"."noteId" = ${Notes.id})`
- : undefined,
- pinned
- ? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
- : undefined,
- // Visibility check
- or(
- sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${otherUser.id})`,
- and(
- sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
- inArray(Notes.visibility, ["public", "private"]),
- ),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- exclude_reblogs ? isNull(Notes.reblogId) : undefined,
- exclude_replies ? isNull(Notes.replyId) : undefined,
- ),
- limit,
- new URL(context.req.url),
- user?.id,
- );
-
- return context.json(
- await Promise.all(objects.map((note) => note.toApi(otherUser))),
- 200,
- );
- }),
-);
diff --git a/api/api/v1/accounts/:id/unblock.ts b/api/api/v1/accounts/:id/unblock.ts
deleted file mode 100644
index f2b4377a..00000000
--- a/api/api/v1/accounts/:id/unblock.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/unblock",
- summary: "Unblock account",
- description: "Unblock the given account.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#unblock",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:blocks"],
- permissions: [
- RolePermission.ManageOwnBlocks,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description:
- "Successfully unblocked, or account was already not blocked",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const otherUser = context.get("user");
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- if (foundRelationship.data.blocking) {
- await foundRelationship.update({
- blocking: false,
- });
- }
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/unfollow.ts b/api/api/v1/accounts/:id/unfollow.ts
deleted file mode 100644
index 29f7f355..00000000
--- a/api/api/v1/accounts/:id/unfollow.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/unfollow",
- summary: "Unfollow account",
- description: "Unfollow the given account.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#unfollow",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:follows"],
- permissions: [
- RolePermission.ManageOwnFollows,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description:
- "Successfully unfollowed, or account was already not followed",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const otherUser = context.get("user");
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- await user.unfollow(otherUser, foundRelationship);
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/unmute.ts b/api/api/v1/accounts/:id/unmute.ts
deleted file mode 100644
index 2de4a3c8..00000000
--- a/api/api/v1/accounts/:id/unmute.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/unmute",
- summary: "Unmute account",
- description: "Unmute the given account.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#unmute",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:mutes"],
- permissions: [
- RolePermission.ManageOwnMutes,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Successfully unmuted, or account was already unmuted",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const otherUser = context.get("user");
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- if (foundRelationship.data.muting) {
- await foundRelationship.update({
- muting: false,
- mutingNotifications: false,
- });
- }
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/unpin.ts b/api/api/v1/accounts/:id/unpin.ts
deleted file mode 100644
index ec4ab0e1..00000000
--- a/api/api/v1/accounts/:id/unpin.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { apiRoute, auth, withUserParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts/{id}/unpin",
- summary: "Unfeature account from profile",
- description: "Remove the given account from the user’s featured profiles.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#unpin",
- },
- tags: ["Accounts"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:accounts"],
- permissions: [
- RolePermission.ManageOwnAccount,
- RolePermission.ViewAccounts,
- ],
- }),
- withUserParam,
- ] as const,
- request: {
- params: z.object({
- id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description:
- "Successfully unendorsed, or account was already not endorsed",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const otherUser = context.get("user");
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- otherUser,
- );
-
- if (foundRelationship.data.endorsed) {
- await foundRelationship.update({
- endorsed: false,
- });
- }
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/accounts/:id/block.test.ts b/api/api/v1/accounts/[id]/block.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/block.test.ts
rename to api/api/v1/accounts/[id]/block.test.ts
diff --git a/api/api/v1/accounts/[id]/block.ts b/api/api/v1/accounts/[id]/block.ts
new file mode 100644
index 00000000..8432af82
--- /dev/null
+++ b/api/api/v1/accounts/[id]/block.ts
@@ -0,0 +1,62 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Relationship as RelationshipSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/block",
+ describeRoute({
+ summary: "Block account",
+ description:
+ "Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#block",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "Successfully blocked, or account was already blocked.",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:blocks"],
+ permissions: [
+ RolePermission.ManageOwnBlocks,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+ const otherUser = context.get("user");
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ if (!foundRelationship.data.blocking) {
+ await foundRelationship.update({
+ blocking: true,
+ });
+ }
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/follow.test.ts b/api/api/v1/accounts/[id]/follow.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/follow.test.ts
rename to api/api/v1/accounts/[id]/follow.test.ts
diff --git a/api/api/v1/accounts/[id]/follow.ts b/api/api/v1/accounts/[id]/follow.ts
new file mode 100644
index 00000000..b0ac881c
--- /dev/null
+++ b/api/api/v1/accounts/[id]/follow.ts
@@ -0,0 +1,102 @@
+import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
+import {
+ Relationship as RelationshipSchema,
+ iso631,
+} from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/follow",
+ describeRoute({
+ summary: "Follow account",
+ description:
+ "Follow the given account. Can also be used to update whether to show reblogs or enable notifications.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#follow",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "Successfully followed, or account was already followed",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 403: {
+ description:
+ "Trying to follow someone that you block or that blocks you",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:follows"],
+ permissions: [
+ RolePermission.ManageOwnFollows,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ validator(
+ "json",
+ z.object({
+ reblogs: z.boolean().default(true).openapi({
+ description:
+ "Receive this account’s reblogs in home timeline?",
+ example: true,
+ }),
+ notify: z.boolean().default(false).openapi({
+ description:
+ "Receive notifications when this account posts a status?",
+ example: false,
+ }),
+ languages: z
+ .array(iso631)
+ .default([])
+ .openapi({
+ description:
+ "Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.",
+ example: ["en", "fr"],
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { reblogs, notify, languages } = context.req.valid("json");
+ const otherUser = context.get("user");
+
+ let relationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ if (!relationship.data.following) {
+ relationship = await user.followRequest(otherUser, {
+ reblogs,
+ notify,
+ languages,
+ });
+ }
+
+ return context.json(relationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/followers.test.ts b/api/api/v1/accounts/[id]/followers.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/followers.test.ts
rename to api/api/v1/accounts/[id]/followers.test.ts
diff --git a/api/api/v1/accounts/[id]/followers.ts b/api/api/v1/accounts/[id]/followers.ts
new file mode 100644
index 00000000..aa3de792
--- /dev/null
+++ b/api/api/v1/accounts/[id]/followers.ts
@@ -0,0 +1,111 @@
+import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
+import { Account as AccountSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Timeline } from "@versia/kit/db";
+import { Users } from "@versia/kit/tables";
+import { and, gt, gte, lt, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/accounts/:id/followers",
+ describeRoute({
+ summary: "Get account’s followers",
+ description:
+ "Accounts which follow the given account, if network is not hidden by the account owner.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#followers",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Accounts which follow the given account.",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(AccountSchema)),
+ },
+ },
+ headers: resolver(
+ z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ ),
+ },
+ 404: ApiError.accountNotFound().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: false,
+ scopes: ["read:accounts"],
+ permissions: [
+ RolePermission.ViewAccountFollows,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ validator(
+ "query",
+ z.object({
+ max_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.number().int().min(1).max(40).default(20).openapi({
+ description: "Maximum number of results to return.",
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { max_id, since_id, min_id, limit } =
+ context.req.valid("query");
+ const otherUser = context.get("user");
+
+ // TODO: Add follower/following privacy settings
+ const { objects, link } = await Timeline.getUserTimeline(
+ and(
+ max_id ? lt(Users.id, max_id) : undefined,
+ since_id ? gte(Users.id, since_id) : undefined,
+ min_id ? gt(Users.id, min_id) : undefined,
+ sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
+ ),
+ limit,
+ new URL(context.req.url),
+ );
+
+ return context.json(
+ await Promise.all(objects.map((object) => object.toApi())),
+ 200,
+ {
+ Link: link,
+ },
+ );
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/following.test.ts b/api/api/v1/accounts/[id]/following.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/following.test.ts
rename to api/api/v1/accounts/[id]/following.test.ts
diff --git a/api/api/v1/accounts/[id]/following.ts b/api/api/v1/accounts/[id]/following.ts
new file mode 100644
index 00000000..4867e30b
--- /dev/null
+++ b/api/api/v1/accounts/[id]/following.ts
@@ -0,0 +1,112 @@
+import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
+import { Account as AccountSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Timeline } from "@versia/kit/db";
+import { Users } from "@versia/kit/tables";
+import { and, gt, gte, lt, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/accounts/:id/following",
+ describeRoute({
+ summary: "Get account’s following",
+ description:
+ "Accounts which the given account is following, if network is not hidden by the account owner.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#following",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "Accounts which the given account is following.",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(AccountSchema)),
+ },
+ },
+ headers: resolver(
+ z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ ),
+ },
+ 404: ApiError.accountNotFound().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: false,
+ scopes: ["read:accounts"],
+ permissions: [
+ RolePermission.ViewAccountFollows,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ validator(
+ "query",
+ z.object({
+ max_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.number().int().min(1).max(40).default(20).openapi({
+ description: "Maximum number of results to return.",
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { max_id, since_id, min_id } = context.req.valid("query");
+ const otherUser = context.get("user");
+
+ // TODO: Add follower/following privacy settings
+
+ const { objects, link } = await Timeline.getUserTimeline(
+ and(
+ max_id ? lt(Users.id, max_id) : undefined,
+ since_id ? gte(Users.id, since_id) : undefined,
+ min_id ? gt(Users.id, min_id) : undefined,
+ sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
+ ),
+ context.req.valid("query").limit,
+ new URL(context.req.url),
+ );
+
+ return context.json(
+ await Promise.all(objects.map((object) => object.toApi())),
+ 200,
+ {
+ Link: link,
+ },
+ );
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/index.test.ts b/api/api/v1/accounts/[id]/index.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/index.test.ts
rename to api/api/v1/accounts/[id]/index.test.ts
diff --git a/api/api/v1/accounts/[id]/index.ts b/api/api/v1/accounts/[id]/index.ts
new file mode 100644
index 00000000..c4df5968
--- /dev/null
+++ b/api/api/v1/accounts/[id]/index.ts
@@ -0,0 +1,47 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Account as AccountSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/accounts/:id",
+ describeRoute({
+ summary: "Get account",
+ description: "View information about a profile.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#get",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "The Account record will be returned. Note that acct of local users does not include the domain name.",
+ content: {
+ "application/json": {
+ schema: resolver(AccountSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: false,
+ permissions: [RolePermission.ViewAccounts],
+ }),
+ (context) => {
+ const { user } = context.get("auth");
+ const otherUser = context.get("user");
+
+ return context.json(
+ otherUser.toApi(user?.id === otherUser.id),
+ 200,
+ );
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/mute.test.ts b/api/api/v1/accounts/[id]/mute.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/mute.test.ts
rename to api/api/v1/accounts/[id]/mute.test.ts
diff --git a/api/api/v1/accounts/[id]/mute.ts b/api/api/v1/accounts/[id]/mute.ts
new file mode 100644
index 00000000..c40ee190
--- /dev/null
+++ b/api/api/v1/accounts/[id]/mute.ts
@@ -0,0 +1,84 @@
+import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
+import { Relationship as RelationshipSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/mute",
+ describeRoute({
+ summary: "Mute account",
+ description:
+ "Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#mute",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "Successfully muted, or account was already muted. Note that you can call this API method again with notifications=false to update the relationship so that only statuses are muted.",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:mutes"],
+ permissions: [
+ RolePermission.ManageOwnMutes,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ validator(
+ "json",
+ z.object({
+ notifications: z.boolean().default(true).openapi({
+ description: "Mute notifications in addition to statuses?",
+ }),
+ duration: z
+ .number()
+ .int()
+ .min(0)
+ .max(60 * 60 * 24 * 365 * 5)
+ .default(0)
+ .openapi({
+ description:
+ "How long the mute should last, in seconds.",
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ // TODO: Add duration support
+ const { notifications } = context.req.valid("json");
+ const otherUser = context.get("user");
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ // TODO: Implement duration
+ await foundRelationship.update({
+ muting: true,
+ mutingNotifications: notifications,
+ });
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/note.test.ts b/api/api/v1/accounts/[id]/note.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/note.test.ts
rename to api/api/v1/accounts/[id]/note.test.ts
diff --git a/api/api/v1/accounts/[id]/note.ts b/api/api/v1/accounts/[id]/note.ts
new file mode 100644
index 00000000..a85b79ef
--- /dev/null
+++ b/api/api/v1/accounts/[id]/note.ts
@@ -0,0 +1,70 @@
+import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
+import { Relationship as RelationshipSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/note",
+ describeRoute({
+ summary: "Set private note on profile",
+ description: "Sets a private note on a user.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#note",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Successfully updated profile note",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:accounts"],
+ permissions: [
+ RolePermission.ManageOwnAccount,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ validator(
+ "json",
+ z.object({
+ comment: RelationshipSchema.shape.note.optional().openapi({
+ description:
+ "The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { comment } = context.req.valid("json");
+ const otherUser = context.get("user");
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ await foundRelationship.update({
+ note: comment ?? "",
+ });
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/pin.test.ts b/api/api/v1/accounts/[id]/pin.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/pin.test.ts
rename to api/api/v1/accounts/[id]/pin.test.ts
diff --git a/api/api/v1/accounts/[id]/pin.ts b/api/api/v1/accounts/[id]/pin.ts
new file mode 100644
index 00000000..586cc8f1
--- /dev/null
+++ b/api/api/v1/accounts/[id]/pin.ts
@@ -0,0 +1,54 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Relationship as RelationshipSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/pin",
+ describeRoute({
+ summary: "Feature account on your profile",
+ description:
+ "Add the given account to the user’s featured profiles. (Featured profiles are currently shown on the user’s own public profile.)",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#pin",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Updated relationship",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:accounts"],
+ permissions: [
+ RolePermission.ManageOwnAccount,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+ const otherUser = context.get("user");
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ await foundRelationship.update({
+ endorsed: true,
+ });
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/[id]/refetch.ts b/api/api/v1/accounts/[id]/refetch.ts
new file mode 100644
index 00000000..954e4577
--- /dev/null
+++ b/api/api/v1/accounts/[id]/refetch.ts
@@ -0,0 +1,56 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Account as AccountSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/refetch",
+ describeRoute({
+ summary: "Refetch account",
+ description:
+ "Refetch the given account's profile from the remote server",
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Refetched account data",
+ content: {
+ "application/json": {
+ schema: resolver(AccountSchema),
+ },
+ },
+ },
+ 400: {
+ description: "User is local",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:accounts"],
+ permissions: [RolePermission.ViewAccounts],
+ }),
+ async (context) => {
+ const otherUser = context.get("user");
+
+ if (otherUser.isLocal()) {
+ throw new ApiError(400, "Cannot refetch a local user");
+ }
+
+ const newUser = await otherUser.updateFromRemote();
+
+ return context.json(newUser.toApi(false), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/remove_from_followers.test.ts b/api/api/v1/accounts/[id]/remove_from_followers.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/remove_from_followers.test.ts
rename to api/api/v1/accounts/[id]/remove_from_followers.test.ts
diff --git a/api/api/v1/accounts/[id]/remove_from_followers.ts b/api/api/v1/accounts/[id]/remove_from_followers.ts
new file mode 100644
index 00000000..c5d5a902
--- /dev/null
+++ b/api/api/v1/accounts/[id]/remove_from_followers.ts
@@ -0,0 +1,66 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Relationship as RelationshipSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/remove_from_followers",
+ describeRoute({
+ summary: "Remove account from followers",
+ description: "Remove the given account from your followers.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#remove_from_followers",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "Successfully removed from followers, or account was already not following you",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:follows"],
+ permissions: [
+ RolePermission.ManageOwnFollows,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+ const otherUser = context.get("user");
+
+ const oppositeRelationship = await Relationship.fromOwnerAndSubject(
+ otherUser,
+ user,
+ );
+
+ if (oppositeRelationship.data.following) {
+ await oppositeRelationship.update({
+ following: false,
+ });
+ }
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/roles/:role_id/index.test.ts b/api/api/v1/accounts/[id]/roles/[role_id]/index.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/roles/:role_id/index.test.ts
rename to api/api/v1/accounts/[id]/roles/[role_id]/index.test.ts
diff --git a/api/api/v1/accounts/[id]/roles/[role_id]/index.ts b/api/api/v1/accounts/[id]/roles/[role_id]/index.ts
new file mode 100644
index 00000000..b11d5baa
--- /dev/null
+++ b/api/api/v1/accounts/[id]/roles/[role_id]/index.ts
@@ -0,0 +1,127 @@
+import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
+import {
+ Account as AccountSchema,
+ Role as RoleSchema,
+} from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Role } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) => {
+ app.post(
+ "/api/v1/accounts/:id/roles/:role_id",
+ describeRoute({
+ summary: "Assign role to account",
+ tags: ["Accounts"],
+ responses: {
+ 204: {
+ description: "Role assigned",
+ },
+ 404: ApiError.roleNotFound().schema,
+ 403: ApiError.forbidden().schema,
+ },
+ }),
+ withUserParam,
+ validator(
+ "param",
+ z.object({
+ id: AccountSchema.shape.id,
+ role_id: RoleSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageRoles],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { role_id } = context.req.valid("param");
+ const targetUser = context.get("user");
+
+ const role = await Role.fromId(role_id);
+
+ if (!role) {
+ throw ApiError.roleNotFound();
+ }
+ // Priority check
+ const userRoles = await Role.getUserRoles(
+ user.id,
+ user.data.isAdmin,
+ );
+
+ const userHighestRole = userRoles.reduce((prev, current) =>
+ prev.data.priority > current.data.priority ? prev : current,
+ );
+
+ if (role.data.priority > userHighestRole.data.priority) {
+ throw new ApiError(
+ 403,
+ "Forbidden",
+ `User with highest role priority ${userHighestRole.data.priority} cannot assign role with priority ${role.data.priority}`,
+ );
+ }
+
+ await role.linkUser(targetUser.id);
+
+ return context.body(null, 204);
+ },
+ );
+
+ app.delete(
+ "/api/v1/accounts/:id/roles/:role_id",
+ describeRoute({
+ summary: "Remove role from user",
+ tags: ["Accounts"],
+ }),
+ withUserParam,
+ validator(
+ "param",
+ z.object({
+ id: AccountSchema.shape.id,
+ role_id: RoleSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageRoles],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { role_id } = context.req.valid("param");
+ const targetUser = context.get("user");
+
+ const role = await Role.fromId(role_id);
+
+ if (!role) {
+ throw ApiError.roleNotFound();
+ }
+
+ // Priority check
+ const userRoles = await Role.getUserRoles(
+ user.id,
+ user.data.isAdmin,
+ );
+
+ const userHighestRole = userRoles.reduce((prev, current) =>
+ prev.data.priority > current.data.priority ? prev : current,
+ );
+
+ if (role.data.priority > userHighestRole.data.priority) {
+ throw new ApiError(
+ 403,
+ "Forbidden",
+ `User with highest role priority ${userHighestRole.data.priority} cannot remove role with priority ${role.data.priority}`,
+ );
+ }
+
+ await role.unlinkUser(targetUser.id);
+
+ return context.body(null, 204);
+ },
+ );
+});
diff --git a/api/api/v1/accounts/:id/roles/index.test.ts b/api/api/v1/accounts/[id]/roles/index.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/roles/index.test.ts
rename to api/api/v1/accounts/[id]/roles/index.test.ts
diff --git a/api/api/v1/accounts/[id]/roles/index.ts b/api/api/v1/accounts/[id]/roles/index.ts
new file mode 100644
index 00000000..1e37066c
--- /dev/null
+++ b/api/api/v1/accounts/[id]/roles/index.ts
@@ -0,0 +1,43 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Role as RoleSchema } from "@versia/client/schemas";
+import { Role } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { z } from "zod";
+
+export default apiRoute((app) => {
+ app.get(
+ "/api/v1/accounts/:id/roles",
+ describeRoute({
+ summary: "List account roles",
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "List of roles",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(RoleSchema)),
+ },
+ },
+ },
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: false,
+ }),
+ async (context) => {
+ const targetUser = context.get("user");
+
+ const roles = await Role.getUserRoles(
+ targetUser.id,
+ targetUser.data.isAdmin,
+ );
+
+ return context.json(
+ roles.map((role) => role.toApi()),
+ 200,
+ );
+ },
+ );
+});
diff --git a/api/api/v1/accounts/:id/statuses.test.ts b/api/api/v1/accounts/[id]/statuses.test.ts
similarity index 94%
rename from api/api/v1/accounts/:id/statuses.test.ts
rename to api/api/v1/accounts/[id]/statuses.test.ts
index 60dce3cf..012e133a 100644
--- a/api/api/v1/accounts/:id/statuses.test.ts
+++ b/api/api/v1/accounts/[id]/statuses.test.ts
@@ -95,7 +95,7 @@ describe("/api/v1/accounts/:id/statuses", () => {
expect(ok2).toBe(true);
- const { data: data2, ok: ok3 } = await client0.getAccountStatuses(
+ const { data: data3, ok: ok3 } = await client0.getAccountStatuses(
users[1].id,
{
pinned: true,
@@ -103,7 +103,7 @@ describe("/api/v1/accounts/:id/statuses", () => {
);
expect(ok3).toBe(true);
- expect(data2).toBeArrayOfSize(1);
- expect(data2[0].id).toBe(timeline[3].id);
+ expect(data3).toBeArrayOfSize(1);
+ expect(data3[0].id).toBe(timeline[3].id);
});
});
diff --git a/api/api/v1/accounts/[id]/statuses.ts b/api/api/v1/accounts/[id]/statuses.ts
new file mode 100644
index 00000000..0344d3a3
--- /dev/null
+++ b/api/api/v1/accounts/[id]/statuses.ts
@@ -0,0 +1,142 @@
+import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
+import { Status as StatusSchema, zBoolean } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Timeline } from "@versia/kit/db";
+import { Notes } from "@versia/kit/tables";
+import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/accounts/:id/statuses",
+ describeRoute({
+ summary: "Get account’s statuses",
+ description: "Statuses posted to the given account.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#statuses",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Statuses posted to the given account.",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(StatusSchema)),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: false,
+ permissions: [
+ RolePermission.ViewNotes,
+ RolePermission.ViewAccounts,
+ ],
+ scopes: ["read:statuses"],
+ }),
+ validator(
+ "query",
+ z.object({
+ max_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(40)
+ .default(20)
+ .openapi({
+ description: "Maximum number of results to return.",
+ }),
+ only_media: zBoolean.default(false).openapi({
+ description: "Filter out statuses without attachments.",
+ }),
+ exclude_replies: zBoolean.default(false).openapi({
+ description:
+ "Filter out statuses in reply to a different account.",
+ }),
+ exclude_reblogs: zBoolean.default(false).openapi({
+ description: "Filter out boosts from the response.",
+ }),
+ pinned: zBoolean.default(false).openapi({
+ description:
+ "Filter for pinned statuses only. Pinned statuses do not receive special priority in the order of the returned results.",
+ }),
+ tagged: z.string().optional().openapi({
+ description:
+ "Filter for statuses using a specific hashtag.",
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const otherUser = context.get("user");
+
+ const {
+ max_id,
+ min_id,
+ since_id,
+ limit,
+ exclude_reblogs,
+ only_media,
+ exclude_replies,
+ pinned,
+ } = context.req.valid("query");
+
+ const { objects } = await Timeline.getNoteTimeline(
+ and(
+ max_id ? lt(Notes.id, max_id) : undefined,
+ since_id ? gte(Notes.id, since_id) : undefined,
+ min_id ? gt(Notes.id, min_id) : undefined,
+ eq(Notes.authorId, otherUser.id),
+ only_media
+ ? sql`EXISTS (SELECT 1 FROM "Medias" WHERE "Medias"."noteId" = ${Notes.id})`
+ : undefined,
+ pinned
+ ? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
+ : undefined,
+ // Visibility check
+ or(
+ sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${otherUser.id})`,
+ and(
+ sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
+ inArray(Notes.visibility, ["public", "private"]),
+ ),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ exclude_reblogs ? isNull(Notes.reblogId) : undefined,
+ exclude_replies ? isNull(Notes.replyId) : undefined,
+ ),
+ limit,
+ new URL(context.req.url),
+ user?.id,
+ );
+
+ return context.json(
+ await Promise.all(objects.map((note) => note.toApi(otherUser))),
+ 200,
+ );
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/unblock.test.ts b/api/api/v1/accounts/[id]/unblock.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/unblock.test.ts
rename to api/api/v1/accounts/[id]/unblock.test.ts
diff --git a/api/api/v1/accounts/[id]/unblock.ts b/api/api/v1/accounts/[id]/unblock.ts
new file mode 100644
index 00000000..2294a349
--- /dev/null
+++ b/api/api/v1/accounts/[id]/unblock.ts
@@ -0,0 +1,61 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Relationship as RelationshipSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/unblock",
+ describeRoute({
+ summary: "Unblock account",
+ description: "Unblock the given account.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#unblock",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "Successfully unblocked, or account was already not blocked",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:blocks"],
+ permissions: [
+ RolePermission.ManageOwnBlocks,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+ const otherUser = context.get("user");
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ if (foundRelationship.data.blocking) {
+ await foundRelationship.update({
+ blocking: false,
+ });
+ }
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/[id]/unfollow.ts b/api/api/v1/accounts/[id]/unfollow.ts
new file mode 100644
index 00000000..8810130a
--- /dev/null
+++ b/api/api/v1/accounts/[id]/unfollow.ts
@@ -0,0 +1,57 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Relationship as RelationshipSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/unfollow",
+ describeRoute({
+ summary: "Unfollow account",
+ description: "Unfollow the given account.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#unfollow",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "Successfully unfollowed, or account was already not followed",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:follows"],
+ permissions: [
+ RolePermission.ManageOwnFollows,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+ const otherUser = context.get("user");
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ await user.unfollow(otherUser, foundRelationship);
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/unmute.test.ts b/api/api/v1/accounts/[id]/unmute.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/unmute.test.ts
rename to api/api/v1/accounts/[id]/unmute.test.ts
diff --git a/api/api/v1/accounts/[id]/unmute.ts b/api/api/v1/accounts/[id]/unmute.ts
new file mode 100644
index 00000000..f92fc039
--- /dev/null
+++ b/api/api/v1/accounts/[id]/unmute.ts
@@ -0,0 +1,62 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Relationship as RelationshipSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/unmute",
+ describeRoute({
+ summary: "Unmute account",
+ description: "Unmute the given account.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#unmute",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "Successfully unmuted, or account was already unmuted",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:mutes"],
+ permissions: [
+ RolePermission.ManageOwnMutes,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+ const otherUser = context.get("user");
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ if (foundRelationship.data.muting) {
+ await foundRelationship.update({
+ muting: false,
+ mutingNotifications: false,
+ });
+ }
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/:id/unpin.test.ts b/api/api/v1/accounts/[id]/unpin.test.ts
similarity index 100%
rename from api/api/v1/accounts/:id/unpin.test.ts
rename to api/api/v1/accounts/[id]/unpin.test.ts
diff --git a/api/api/v1/accounts/[id]/unpin.ts b/api/api/v1/accounts/[id]/unpin.ts
new file mode 100644
index 00000000..e0bf1fd6
--- /dev/null
+++ b/api/api/v1/accounts/[id]/unpin.ts
@@ -0,0 +1,62 @@
+import { apiRoute, auth, withUserParam } from "@/api";
+import { Relationship as RelationshipSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts/:id/unpin",
+ describeRoute({
+ summary: "Unfeature account from profile",
+ description:
+ "Remove the given account from the user’s featured profiles.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#unpin",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description:
+ "Successfully unendorsed, or account was already not endorsed",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ withUserParam,
+ auth({
+ auth: true,
+ scopes: ["write:accounts"],
+ permissions: [
+ RolePermission.ManageOwnAccount,
+ RolePermission.ViewAccounts,
+ ],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+ const otherUser = context.get("user");
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ otherUser,
+ );
+
+ if (foundRelationship.data.endorsed) {
+ await foundRelationship.update({
+ endorsed: false,
+ });
+ }
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/accounts/familiar_followers/index.ts b/api/api/v1/accounts/familiar_followers/index.ts
index 1b19e29c..e1cb1ded 100644
--- a/api/api/v1/accounts/familiar_followers/index.ts
+++ b/api/api/v1/accounts/familiar_followers/index.ts
@@ -1,5 +1,4 @@
-import { apiRoute, auth, qsQuery } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
import {
Account as AccountSchema,
FamiliarFollowers as FamiliarFollowersSchema,
@@ -8,71 +7,75 @@ import { RolePermission } from "@versia/client/schemas";
import { User, db } from "@versia/kit/db";
import type { Users } from "@versia/kit/tables";
import { type InferSelectModel, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit";
-const route = createRoute({
- method: "get",
- path: "/api/v1/accounts/familiar_followers",
- summary: "Get familiar followers",
- description:
- "Obtain a list of all accounts that follow a given account, filtered for accounts you follow.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#familiar_followers",
- },
- tags: ["Accounts"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/accounts/familiar_followers",
+ describeRoute({
+ summary: "Get familiar followers",
+ description:
+ "Obtain a list of all accounts that follow a given account, filtered for accounts you follow.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#familiar_followers",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Familiar followers",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(FamiliarFollowersSchema)),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ qsQuery(),
auth({
auth: true,
scopes: ["read:follows"],
permissions: [RolePermission.ManageOwnFollows],
}),
rateLimit(5),
- qsQuery(),
- ] as const,
- request: {
- query: z.object({
- id: z
- .array(AccountSchema.shape.id)
- .min(1)
- .max(10)
- .or(AccountSchema.shape.id.transform((v) => [v]))
- .openapi({
- description:
- "Find familiar followers for the provided account IDs.",
- example: [
- "f137ce6f-ff5e-4998-b20f-0361ba9be007",
- "8424c654-5d03-4a1b-bec8-4e87db811b5d",
- ],
- }),
- }),
- },
- responses: {
- 200: {
- description: "Familiar followers",
- content: {
- "application/json": {
- schema: z.array(FamiliarFollowersSchema),
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
+ validator(
+ "query",
+ z.object({
+ id: z
+ .array(AccountSchema.shape.id)
+ .min(1)
+ .max(10)
+ .or(AccountSchema.shape.id.transform((v) => [v]))
+ .openapi({
+ description:
+ "Find familiar followers for the provided account IDs.",
+ example: [
+ "f137ce6f-ff5e-4998-b20f-0361ba9be007",
+ "8424c654-5d03-4a1b-bec8-4e87db811b5d",
+ ],
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { id: ids } = context.req.valid("query");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const { id: ids } = context.req.valid("query");
-
- // Find followers of the accounts in "ids", that you also follow
- const finalUsers = await Promise.all(
- ids.map(async (id) => ({
- id,
- accounts: await User.fromIds(
- (
- await db.execute(sql>`
+ // Find followers of the accounts in "ids", that you also follow
+ const finalUsers = await Promise.all(
+ ids.map(async (id) => ({
+ id,
+ accounts: await User.fromIds(
+ (
+ await db.execute(sql<
+ InferSelectModel
+ >`
SELECT "Users"."id" FROM "Users"
INNER JOIN "Relationships" AS "SelfFollowing"
ON "SelfFollowing"."subjectId" = "Users"."id"
@@ -85,17 +88,18 @@ export default apiRoute((app) =>
AND "IdsFollowers"."following" = true
)
`)
- ).rows.map((u) => u.id as string),
- ),
- })),
- );
+ ).rows.map((u) => u.id as string),
+ ),
+ })),
+ );
- return context.json(
- finalUsers.map((u) => ({
- ...u,
- accounts: u.accounts.map((a) => a.toApi()),
- })),
- 200,
- );
- }),
+ return context.json(
+ finalUsers.map((u) => ({
+ ...u,
+ accounts: u.accounts.map((a) => a.toApi()),
+ })),
+ 200,
+ );
+ },
+ ),
);
diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts
index 781070fd..31a38585 100644
--- a/api/api/v1/accounts/index.ts
+++ b/api/api/v1/accounts/index.ts
@@ -1,11 +1,13 @@
-import { apiRoute, auth, jsonOrForm } from "@/api";
+import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { tempmailDomains } from "@/tempmail";
-import { createRoute, z } from "@hono/zod-openapi";
import { zBoolean } from "@versia/client/schemas";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
import ISO6391 from "iso-639-1";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit";
@@ -40,17 +42,100 @@ const schema = z.object({
}),
});
-const route = createRoute({
- method: "post",
- path: "/api/v1/accounts",
- summary: "Register an account",
- description:
- "Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.\n\nA relationship between the OAuth Application and created user account is stored.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#create",
- },
- tags: ["Accounts"],
- middleware: [
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/accounts",
+ describeRoute({
+ summary: "Register an account",
+ description:
+ "Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.\n\nA relationship between the OAuth Application and created user account is stored.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#create",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Token for the created account",
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: {
+ description: "Validation failed",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.object({
+ error: z.string(),
+ details: z.object({
+ username: z.array(
+ z.object({
+ error: z.enum([
+ "ERR_BLANK",
+ "ERR_INVALID",
+ "ERR_TOO_LONG",
+ "ERR_TOO_SHORT",
+ "ERR_BLOCKED",
+ "ERR_TAKEN",
+ "ERR_RESERVED",
+ "ERR_ACCEPTED",
+ "ERR_INCLUSION",
+ ]),
+ description: z.string(),
+ }),
+ ),
+ email: z.array(
+ z.object({
+ error: z.enum([
+ "ERR_BLANK",
+ "ERR_INVALID",
+ "ERR_BLOCKED",
+ "ERR_TAKEN",
+ ]),
+ description: z.string(),
+ }),
+ ),
+ password: z.array(
+ z.object({
+ error: z.enum([
+ "ERR_BLANK",
+ "ERR_INVALID",
+ "ERR_TOO_LONG",
+ "ERR_TOO_SHORT",
+ ]),
+ description: z.string(),
+ }),
+ ),
+ agreement: z.array(
+ z.object({
+ error: z.enum(["ERR_ACCEPTED"]),
+ description: z.string(),
+ }),
+ ),
+ locale: z.array(
+ z.object({
+ error: z.enum([
+ "ERR_BLANK",
+ "ERR_INVALID",
+ ]),
+ description: z.string(),
+ }),
+ ),
+ reason: z.array(
+ z.object({
+ error: z.enum([
+ "ERR_BLANK",
+ "ERR_TOO_LONG",
+ ]),
+ description: z.string(),
+ }),
+ ),
+ }),
+ }),
+ ),
+ },
+ },
+ },
+ },
+ }),
auth({
auth: false,
scopes: ["write:accounts"],
@@ -58,315 +143,223 @@ const route = createRoute({
}),
rateLimit(5),
jsonOrForm(),
- ] as const,
- request: {
- body: {
- content: {
- "application/json": {
- schema,
- },
- "multipart/form-data": {
- schema,
- },
- "application/x-www-form-urlencoded": {
- schema,
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Token for the created account",
- },
- 401: ApiError.missingAuthentication().schema,
- 422: {
- description: "Validation failed",
- content: {
- "application/json": {
- schema: z.object({
- error: z.string(),
- details: z.object({
- username: z.array(
- z.object({
- error: z.enum([
- "ERR_BLANK",
- "ERR_INVALID",
- "ERR_TOO_LONG",
- "ERR_TOO_SHORT",
- "ERR_BLOCKED",
- "ERR_TAKEN",
- "ERR_RESERVED",
- "ERR_ACCEPTED",
- "ERR_INCLUSION",
- ]),
- description: z.string(),
- }),
- ),
- email: z.array(
- z.object({
- error: z.enum([
- "ERR_BLANK",
- "ERR_INVALID",
- "ERR_BLOCKED",
- "ERR_TAKEN",
- ]),
- description: z.string(),
- }),
- ),
- password: z.array(
- z.object({
- error: z.enum([
- "ERR_BLANK",
- "ERR_INVALID",
- "ERR_TOO_LONG",
- "ERR_TOO_SHORT",
- ]),
- description: z.string(),
- }),
- ),
- agreement: z.array(
- z.object({
- error: z.enum(["ERR_ACCEPTED"]),
- description: z.string(),
- }),
- ),
- locale: z.array(
- z.object({
- error: z.enum(["ERR_BLANK", "ERR_INVALID"]),
- description: z.string(),
- }),
- ),
- reason: z.array(
- z.object({
- error: z.enum([
- "ERR_BLANK",
- "ERR_TOO_LONG",
- ]),
- description: z.string(),
- }),
- ),
- }),
- }),
- },
- },
- },
- },
-});
+ validator("json", schema, handleZodError),
+ async (context) => {
+ const form = context.req.valid("json");
+ const { username, email, password, agreement, locale } =
+ context.req.valid("json");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const form = context.req.valid("json");
- const { username, email, password, agreement, locale } =
- context.req.valid("json");
+ if (!config.registration.allow) {
+ throw new ApiError(422, "Registration is disabled");
+ }
- if (!config.registration.allow) {
- throw new ApiError(422, "Registration is disabled");
- }
+ const errors: {
+ details: Record<
+ string,
+ {
+ error:
+ | "ERR_BLANK"
+ | "ERR_INVALID"
+ | "ERR_TOO_LONG"
+ | "ERR_TOO_SHORT"
+ | "ERR_BLOCKED"
+ | "ERR_TAKEN"
+ | "ERR_RESERVED"
+ | "ERR_ACCEPTED"
+ | "ERR_INCLUSION";
+ description: string;
+ }[]
+ >;
+ } = {
+ details: {
+ password: [],
+ username: [],
+ email: [],
+ agreement: [],
+ locale: [],
+ reason: [],
+ },
+ };
- const errors: {
- details: Record<
- string,
- {
- error:
- | "ERR_BLANK"
- | "ERR_INVALID"
- | "ERR_TOO_LONG"
- | "ERR_TOO_SHORT"
- | "ERR_BLOCKED"
- | "ERR_TAKEN"
- | "ERR_RESERVED"
- | "ERR_ACCEPTED"
- | "ERR_INCLUSION";
- description: string;
- }[]
- >;
- } = {
- details: {
- password: [],
- username: [],
- email: [],
- agreement: [],
- locale: [],
- reason: [],
- },
- };
+ // Check if fields are blank
+ for (const value of [
+ "username",
+ "email",
+ "password",
+ "agreement",
+ "locale",
+ "reason",
+ ]) {
+ // @ts-expect-error We don't care about the type here
+ if (!form[value]) {
+ errors.details[value].push({
+ error: "ERR_BLANK",
+ description: "can't be blank",
+ });
+ }
+ }
- // Check if fields are blank
- for (const value of [
- "username",
- "email",
- "password",
- "agreement",
- "locale",
- "reason",
- ]) {
- // @ts-expect-error We don't care about the type here
- if (!form[value]) {
- errors.details[value].push({
+ // Check if username is valid
+ if (!username?.match(/^[a-z0-9_]+$/)) {
+ errors.details.username.push({
+ error: "ERR_INVALID",
+ description:
+ "must only contain lowercase letters, numbers, and underscores",
+ });
+ }
+
+ // Check if username doesnt match filters
+ if (
+ config.validation.filters.username.some((filter) =>
+ filter.test(username),
+ )
+ ) {
+ errors.details.username.push({
+ error: "ERR_INVALID",
+ description: "contains blocked words",
+ });
+ }
+
+ // Check if username is too long
+ if (
+ (username?.length ?? 0) >
+ config.validation.accounts.max_username_characters
+ ) {
+ errors.details.username.push({
+ error: "ERR_TOO_LONG",
+ description: `is too long (maximum is ${config.validation.accounts.max_username_characters} characters)`,
+ });
+ }
+
+ // Check if username is too short
+ if ((username?.length ?? 0) < 3) {
+ errors.details.username.push({
+ error: "ERR_TOO_SHORT",
+ description: "is too short (minimum is 3 characters)",
+ });
+ }
+
+ // Check if username is reserved
+ if (
+ config.validation.accounts.disallowed_usernames.some((filter) =>
+ filter.test(username),
+ )
+ ) {
+ errors.details.username.push({
+ error: "ERR_RESERVED",
+ description: "is reserved",
+ });
+ }
+
+ // Check if username is taken
+ if (
+ await User.fromSql(
+ and(eq(Users.username, username), isNull(Users.instanceId)),
+ )
+ ) {
+ errors.details.username.push({
+ error: "ERR_TAKEN",
+ description: "is already taken",
+ });
+ }
+
+ // Check if email is valid
+ if (
+ !email?.match(
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
+ )
+ ) {
+ errors.details.email.push({
+ error: "ERR_INVALID",
+ description: "must be a valid email address",
+ });
+ }
+
+ // Check if email is blocked
+ if (
+ config.validation.emails.disallowed_domains.some((f) =>
+ f.test(email.split("@")[1]),
+ ) ||
+ (config.validation.emails.disallow_tempmail &&
+ tempmailDomains.domains.includes(email.split("@")[1]))
+ ) {
+ errors.details.email.push({
+ error: "ERR_BLOCKED",
+ description: "is from a blocked email provider",
+ });
+ }
+
+ // Check if email is taken
+ if (await User.fromSql(eq(Users.email, email))) {
+ errors.details.email.push({
+ error: "ERR_TAKEN",
+ description: "is already taken",
+ });
+ }
+
+ // Check if agreement is accepted
+ if (!agreement) {
+ errors.details.agreement.push({
+ error: "ERR_ACCEPTED",
+ description: "must be accepted",
+ });
+ }
+
+ if (!locale) {
+ errors.details.locale.push({
error: "ERR_BLANK",
description: "can't be blank",
});
}
- }
- // Check if username is valid
- if (!username?.match(/^[a-z0-9_]+$/)) {
- errors.details.username.push({
- error: "ERR_INVALID",
- description:
- "must only contain lowercase letters, numbers, and underscores",
- });
- }
+ if (!ISO6391.validate(locale ?? "")) {
+ errors.details.locale.push({
+ error: "ERR_INVALID",
+ description: "must be a valid ISO 639-1 code",
+ });
+ }
- // Check if username doesnt match filters
- if (
- config.validation.filters.username.some((filter) =>
- filter.test(username),
- )
- ) {
- errors.details.username.push({
- error: "ERR_INVALID",
- description: "contains blocked words",
- });
- }
+ // Check if reason is too long
+ if ((form.reason?.length ?? 0) > 10_000) {
+ errors.details.reason.push({
+ error: "ERR_TOO_LONG",
+ description: `is too long (maximum is ${10_000} characters)`,
+ });
+ }
- // Check if username is too long
- if (
- (username?.length ?? 0) >
- config.validation.accounts.max_username_characters
- ) {
- errors.details.username.push({
- error: "ERR_TOO_LONG",
- description: `is too long (maximum is ${config.validation.accounts.max_username_characters} characters)`,
- });
- }
+ // If any errors are present, return them
+ if (
+ Object.values(errors.details).some((value) => value.length > 0)
+ ) {
+ // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
- // Check if username is too short
- if ((username?.length ?? 0) < 3) {
- errors.details.username.push({
- error: "ERR_TOO_SHORT",
- description: "is too short (minimum is 3 characters)",
- });
- }
-
- // Check if username is reserved
- if (
- config.validation.accounts.disallowed_usernames.some((filter) =>
- filter.test(username),
- )
- ) {
- errors.details.username.push({
- error: "ERR_RESERVED",
- description: "is reserved",
- });
- }
-
- // Check if username is taken
- if (
- await User.fromSql(
- and(eq(Users.username, username), isNull(Users.instanceId)),
- )
- ) {
- errors.details.username.push({
- error: "ERR_TAKEN",
- description: "is already taken",
- });
- }
-
- // Check if email is valid
- if (
- !email?.match(
- /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
- )
- ) {
- errors.details.email.push({
- error: "ERR_INVALID",
- description: "must be a valid email address",
- });
- }
-
- // Check if email is blocked
- if (
- config.validation.emails.disallowed_domains.some((f) =>
- f.test(email.split("@")[1]),
- ) ||
- (config.validation.emails.disallow_tempmail &&
- tempmailDomains.domains.includes(email.split("@")[1]))
- ) {
- errors.details.email.push({
- error: "ERR_BLOCKED",
- description: "is from a blocked email provider",
- });
- }
-
- // Check if email is taken
- if (await User.fromSql(eq(Users.email, email))) {
- errors.details.email.push({
- error: "ERR_TAKEN",
- description: "is already taken",
- });
- }
-
- // Check if agreement is accepted
- if (!agreement) {
- errors.details.agreement.push({
- error: "ERR_ACCEPTED",
- description: "must be accepted",
- });
- }
-
- if (!locale) {
- errors.details.locale.push({
- error: "ERR_BLANK",
- description: "can't be blank",
- });
- }
-
- if (!ISO6391.validate(locale ?? "")) {
- errors.details.locale.push({
- error: "ERR_INVALID",
- description: "must be a valid ISO 639-1 code",
- });
- }
-
- // Check if reason is too long
- if ((form.reason?.length ?? 0) > 10_000) {
- errors.details.reason.push({
- error: "ERR_TOO_LONG",
- description: `is too long (maximum is ${10_000} characters)`,
- });
- }
-
- // If any errors are present, return them
- if (Object.values(errors.details).some((value) => value.length > 0)) {
- // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
-
- const errorsText = Object.entries(errors.details)
- .filter(([_, errors]) => errors.length > 0)
- .map(
- ([name, errors]) =>
- `${name} ${errors
- .map((error) => error.description)
- .join(", ")}`,
- )
- .join(", ");
- throw new ApiError(
- 422,
- `Validation failed: ${errorsText}`,
- Object.fromEntries(
- Object.entries(errors.details).filter(
- ([_, errors]) => errors.length > 0,
+ const errorsText = Object.entries(errors.details)
+ .filter(([_, errors]) => errors.length > 0)
+ .map(
+ ([name, errors]) =>
+ `${name} ${errors
+ .map((error) => error.description)
+ .join(", ")}`,
+ )
+ .join(", ");
+ throw new ApiError(
+ 422,
+ `Validation failed: ${errorsText}`,
+ Object.fromEntries(
+ Object.entries(errors.details).filter(
+ ([_, errors]) => errors.length > 0,
+ ),
),
- ),
- );
- }
+ );
+ }
- await User.fromDataLocal({
- username,
- password,
- email,
- });
+ await User.fromDataLocal({
+ username,
+ password,
+ email,
+ });
- return context.text("", 200);
- }),
+ return context.text("", 200);
+ },
+ ),
);
diff --git a/api/api/v1/accounts/lookup/index.ts b/api/api/v1/accounts/lookup/index.ts
index 27e3d43a..7dd68a72 100644
--- a/api/api/v1/accounts/lookup/index.ts
+++ b/api/api/v1/accounts/lookup/index.ts
@@ -1,108 +1,113 @@
-import { apiRoute, auth, parseUserAddress } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError, parseUserAddress } from "@/api";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Instance, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit";
-const route = createRoute({
- method: "get",
- path: "/api/v1/accounts/lookup",
- summary: "Lookup account ID from Webfinger address",
- description:
- "Quickly lookup a username to see if it is available, skipping WebFinger resolution.",
- tags: ["Accounts"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/accounts/lookup",
+ describeRoute({
+ summary: "Lookup account ID from Webfinger address",
+ description:
+ "Quickly lookup a username to see if it is available, skipping WebFinger resolution.",
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Account",
+ content: {
+ "application/json": {
+ schema: resolver(AccountSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: false,
permissions: [RolePermission.Search],
}),
rateLimit(5),
- ] as const,
- request: {
- query: z.object({
- acct: AccountSchema.shape.acct.openapi({
- description: "The username or Webfinger address to lookup.",
- example: "lexi@beta.versia.social",
+ validator(
+ "query",
+ z.object({
+ acct: AccountSchema.shape.acct.openapi({
+ description: "The username or Webfinger address to lookup.",
+ example: "lexi@beta.versia.social",
+ }),
}),
- }),
- },
- responses: {
- 200: {
- description: "Account",
- content: {
- "application/json": {
- schema: AccountSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
+ handleZodError,
+ ),
+ async (context) => {
+ const { acct } = context.req.valid("query");
+ const { user } = context.get("auth");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { acct } = context.req.valid("query");
- const { user } = context.get("auth");
+ // Check if acct is matching format username@domain.com or @username@domain.com
+ const { username, domain } = parseUserAddress(acct);
- // Check if acct is matching format username@domain.com or @username@domain.com
- const { username, domain } = parseUserAddress(acct);
+ // User is local
+ if (!domain || domain === config.http.base_url.host) {
+ const account = await User.fromSql(
+ and(eq(Users.username, username), isNull(Users.instanceId)),
+ );
+
+ if (account) {
+ return context.json(account.toApi(), 200);
+ }
+
+ return context.json(
+ { error: `Account with username ${username} not found` },
+ 404,
+ );
+ }
+
+ // User is remote
+ // Try to fetch it from database
+ const instance = await Instance.resolveFromHost(domain);
+
+ if (!instance) {
+ return context.json(
+ { error: `Instance ${domain} not found` },
+ 404,
+ );
+ }
- // User is local
- if (!domain || domain === config.http.base_url.host) {
const account = await User.fromSql(
- and(eq(Users.username, username), isNull(Users.instanceId)),
+ and(
+ eq(Users.username, username),
+ eq(Users.instanceId, instance.id),
+ ),
);
if (account) {
return context.json(account.toApi(), 200);
}
- return context.json(
- { error: `Account with username ${username} not found` },
- 404,
- );
- }
+ // Fetch from remote instance
+ const manager = await (user ?? User).getFederationRequester();
- // User is remote
- // Try to fetch it from database
- const instance = await Instance.resolveFromHost(domain);
+ const uri = await User.webFinger(manager, username, domain);
- if (!instance) {
- return context.json({ error: `Instance ${domain} not found` }, 404);
- }
+ if (!uri) {
+ throw ApiError.accountNotFound();
+ }
- const account = await User.fromSql(
- and(
- eq(Users.username, username),
- eq(Users.instanceId, instance.id),
- ),
- );
+ const foundAccount = await User.resolve(uri);
- if (account) {
- return context.json(account.toApi(), 200);
- }
+ if (foundAccount) {
+ return context.json(foundAccount.toApi(), 200);
+ }
- // Fetch from remote instance
- const manager = await (user ?? User).getFederationRequester();
-
- const uri = await User.webFinger(manager, username, domain);
-
- if (!uri) {
throw ApiError.accountNotFound();
- }
-
- const foundAccount = await User.resolve(uri);
-
- if (foundAccount) {
- return context.json(foundAccount.toApi(), 200);
- }
-
- throw ApiError.accountNotFound();
- }),
+ },
+ ),
);
diff --git a/api/api/v1/accounts/relationships/index.ts b/api/api/v1/accounts/relationships/index.ts
index a3933647..22545c64 100644
--- a/api/api/v1/accounts/relationships/index.ts
+++ b/api/api/v1/accounts/relationships/index.ts
@@ -1,5 +1,4 @@
-import { apiRoute, auth, qsQuery } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
@@ -7,20 +6,36 @@ import {
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit";
-const route = createRoute({
- method: "get",
- path: "/api/v1/accounts/relationships",
- summary: "Check relationships to other accounts",
- description:
- "Find out whether a given account is followed, blocked, muted, etc.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#relationships",
- },
- tags: ["Accounts"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/accounts/relationships",
+ describeRoute({
+ summary: "Check relationships to other accounts",
+ description:
+ "Find out whether a given account is followed, blocked, muted, etc.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#relationships",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Relationships",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(RelationshipSchema)),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
rateLimit(10),
auth({
auth: true,
@@ -28,65 +43,53 @@ const route = createRoute({
permissions: [RolePermission.ManageOwnFollows],
}),
qsQuery(),
- ] as const,
- request: {
- query: z.object({
- id: z
- .array(AccountSchema.shape.id)
- .min(1)
- .max(10)
- .or(AccountSchema.shape.id.transform((v) => [v]))
- .openapi({
+ validator(
+ "query",
+ z.object({
+ id: z
+ .array(AccountSchema.shape.id)
+ .min(1)
+ .max(10)
+ .or(AccountSchema.shape.id.transform((v) => [v]))
+ .openapi({
+ description:
+ "Check relationships for the provided account IDs.",
+ example: [
+ "f137ce6f-ff5e-4998-b20f-0361ba9be007",
+ "8424c654-5d03-4a1b-bec8-4e87db811b5d",
+ ],
+ }),
+ with_suspended: zBoolean.default(false).openapi({
description:
- "Check relationships for the provided account IDs.",
- example: [
- "f137ce6f-ff5e-4998-b20f-0361ba9be007",
- "8424c654-5d03-4a1b-bec8-4e87db811b5d",
- ],
+ "Whether relationships should be returned for suspended users",
+ example: false,
}),
- with_suspended: zBoolean.default(false).openapi({
- description:
- "Whether relationships should be returned for suspended users",
- example: false,
}),
- }),
- },
- responses: {
- 200: {
- description: "Relationships",
- content: {
- "application/json": {
- schema: z.array(RelationshipSchema),
- },
- },
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+
+ // TODO: Implement with_suspended
+ const { id } = context.req.valid("query");
+
+ const ids = Array.isArray(id) ? id : [id];
+
+ const relationships = await Relationship.fromOwnerAndSubjects(
+ user,
+ ids,
+ );
+
+ relationships.sort(
+ (a, b) =>
+ ids.indexOf(a.data.subjectId) -
+ ids.indexOf(b.data.subjectId),
+ );
+
+ return context.json(
+ relationships.map((r) => r.toApi()),
+ 200,
+ );
},
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
-
- // TODO: Implement with_suspended
- const { id } = context.req.valid("query");
-
- const ids = Array.isArray(id) ? id : [id];
-
- const relationships = await Relationship.fromOwnerAndSubjects(
- user,
- ids,
- );
-
- relationships.sort(
- (a, b) =>
- ids.indexOf(a.data.subjectId) - ids.indexOf(b.data.subjectId),
- );
-
- return context.json(
- relationships.map((r) => r.toApi()),
- 200,
- );
- }),
+ ),
);
diff --git a/api/api/v1/accounts/search/index.ts b/api/api/v1/accounts/search/index.ts
index c58d5b43..30536892 100644
--- a/api/api/v1/accounts/search/index.ts
+++ b/api/api/v1/accounts/search/index.ts
@@ -1,126 +1,138 @@
-import { apiRoute, auth, parseUserAddress } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError, parseUserAddress } from "@/api";
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 { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
import stringComparison from "string-comparison";
+import { z } from "zod";
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: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/accounts/search",
+ describeRoute({
+ 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"],
+ responses: {
+ 200: {
+ description: "Accounts",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(AccountSchema)),
+ },
+ },
+ },
+ },
+ }),
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",
+ validator(
+ "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,
}),
- 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),
- },
- },
- },
- },
-});
+ handleZodError,
+ ),
+ async (context) => {
+ const { q, limit, offset, resolve, following } =
+ context.req.valid("query");
+ const { user } = context.get("auth");
-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);
- }
+ if (!user && following) {
+ throw new ApiError(
+ 401,
+ "Must be authenticated to use 'following'",
+ );
}
- } 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 { 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,
);
- }
-
- 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,
- );
- }),
+ },
+ ),
);
diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts
index 41a23bd7..0ebd3e0c 100644
--- a/api/api/v1/accounts/update_credentials/index.ts
+++ b/api/api/v1/accounts/update_credentials/index.ts
@@ -1,27 +1,42 @@
-import { apiRoute, auth, jsonOrForm } from "@/api";
+import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { mergeAndDeduplicate } from "@/lib";
import { sanitizedHtmlStrip } from "@/sanitization";
-import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema, zBoolean } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Emoji, Media, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml } from "~/classes/functions/status";
import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit";
-const route = createRoute({
- method: "patch",
- path: "/api/v1/accounts/update_credentials",
- summary: "Update account credentials",
- description: "Update the user’s display and preferences.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#update_credentials",
- },
- tags: ["Accounts"],
- middleware: [
+export default apiRoute((app) =>
+ app.patch(
+ "/api/v1/accounts/update_credentials",
+ describeRoute({
+ summary: "Update account credentials",
+ description: "Update the user’s display and preferences.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#update_credentials",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ description: "Updated user",
+ content: {
+ "application/json": {
+ schema: resolver(AccountSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
rateLimit(5),
auth({
auth: true,
@@ -29,341 +44,314 @@ const route = createRoute({
scopes: ["write:accounts"],
}),
jsonOrForm(),
- ] as const,
- request: {
- body: {
- content: {
- "application/json": {
- schema: z
- .object({
- display_name:
- AccountSchema.shape.display_name.openapi({
- description:
- "The display name to use for the profile.",
- example: "Lexi",
- }),
- username: AccountSchema.shape.username.openapi({
- description:
- "The username to use for the profile.",
- example: "lexi",
- }),
- note: AccountSchema.shape.note.openapi({
- description:
- "The account bio. Markdown is supported.",
- }),
- avatar: z
- .string()
- .url()
- .transform((a) => new URL(a))
- .openapi({
- description: "Avatar image URL",
- })
- .or(
- z
- .instanceof(File)
- .refine(
- (v) =>
- v.size <=
- config.validation.accounts
- .max_avatar_bytes,
- `Avatar must be less than ${config.validation.accounts.max_avatar_bytes} bytes`,
- )
- .openapi({
- description:
- "Avatar image encoded using multipart/form-data",
- }),
- ),
- header: z
- .string()
- .url()
- .transform((v) => new URL(v))
- .openapi({
- description: "Header image URL",
- })
- .or(
- z
- .instanceof(File)
- .refine(
- (v) =>
- v.size <=
- config.validation.accounts
- .max_header_bytes,
- `Header must be less than ${config.validation.accounts.max_header_bytes} bytes`,
- )
- .openapi({
- description:
- "Header image encoded using multipart/form-data",
- }),
- ),
- locked: AccountSchema.shape.locked.openapi({
- description:
- "Whether manual approval of follow requests is required.",
- }),
- bot: AccountSchema.shape.bot.openapi({
- description:
- "Whether the account has a bot flag.",
- }),
- discoverable:
- AccountSchema.shape.discoverable.openapi({
- description:
- "Whether the account should be shown in the profile directory.",
- }),
- // TODO: Implement :(
- hide_collections: zBoolean.openapi({
- description:
- "Whether to hide followers and followed accounts.",
- }),
- // TODO: Implement :(
- indexable: zBoolean.openapi({
- description:
- "Whether public posts should be searchable to anyone.",
- }),
- // TODO: Implement :(
- attribution_domains: z.array(z.string()).openapi({
- description:
- "Domains of websites allowed to credit the account.",
- example: ["cnn.com", "myblog.com"],
- }),
- source: z
- .object({
- privacy:
- AccountSchema.shape.source.unwrap()
- .shape.privacy,
- sensitive:
- AccountSchema.shape.source.unwrap()
- .shape.sensitive,
- language:
- AccountSchema.shape.source.unwrap()
- .shape.language,
- })
- .partial(),
- fields_attributes: z
- .array(
- z.object({
- name: AccountSchema.shape.fields.element
- .shape.name,
- value: AccountSchema.shape.fields
- .element.shape.value,
- }),
+ validator(
+ "json",
+ z
+ .object({
+ display_name: AccountSchema.shape.display_name.openapi({
+ description: "The display name to use for the profile.",
+ example: "Lexi",
+ }),
+ username: AccountSchema.shape.username.openapi({
+ description: "The username to use for the profile.",
+ example: "lexi",
+ }),
+ note: AccountSchema.shape.note.openapi({
+ description: "The account bio. Markdown is supported.",
+ }),
+ avatar: z
+ .string()
+ .url()
+ .transform((a) => new URL(a))
+ .openapi({
+ description: "Avatar image URL",
+ })
+ .or(
+ z
+ .instanceof(File)
+ .refine(
+ (v) =>
+ v.size <=
+ config.validation.accounts
+ .max_avatar_bytes,
+ `Avatar must be less than ${config.validation.accounts.max_avatar_bytes} bytes`,
)
- .max(
- config.validation.accounts.max_field_count,
- ),
+ .openapi({
+ description:
+ "Avatar image encoded using multipart/form-data",
+ }),
+ ),
+ header: z
+ .string()
+ .url()
+ .transform((v) => new URL(v))
+ .openapi({
+ description: "Header image URL",
+ })
+ .or(
+ z
+ .instanceof(File)
+ .refine(
+ (v) =>
+ v.size <=
+ config.validation.accounts
+ .max_header_bytes,
+ `Header must be less than ${config.validation.accounts.max_header_bytes} bytes`,
+ )
+ .openapi({
+ description:
+ "Header image encoded using multipart/form-data",
+ }),
+ ),
+ locked: AccountSchema.shape.locked.openapi({
+ description:
+ "Whether manual approval of follow requests is required.",
+ }),
+ bot: AccountSchema.shape.bot.openapi({
+ description: "Whether the account has a bot flag.",
+ }),
+ discoverable: AccountSchema.shape.discoverable.openapi({
+ description:
+ "Whether the account should be shown in the profile directory.",
+ }),
+ // TODO: Implement :(
+ hide_collections: zBoolean.openapi({
+ description:
+ "Whether to hide followers and followed accounts.",
+ }),
+ // TODO: Implement :(
+ indexable: zBoolean.openapi({
+ description:
+ "Whether public posts should be searchable to anyone.",
+ }),
+ // TODO: Implement :(
+ attribution_domains: z.array(z.string()).openapi({
+ description:
+ "Domains of websites allowed to credit the account.",
+ example: ["cnn.com", "myblog.com"],
+ }),
+ source: z
+ .object({
+ privacy:
+ AccountSchema.shape.source.unwrap().shape
+ .privacy,
+ sensitive:
+ AccountSchema.shape.source.unwrap().shape
+ .sensitive,
+ language:
+ AccountSchema.shape.source.unwrap().shape
+ .language,
})
.partial(),
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Updated user",
- content: {
- "application/json": {
- schema: AccountSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
+ fields_attributes: z
+ .array(
+ z.object({
+ name: AccountSchema.shape.fields.element.shape
+ .name,
+ value: AccountSchema.shape.fields.element.shape
+ .value,
+ }),
+ )
+ .max(config.validation.accounts.max_field_count),
+ })
+ .partial(),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const {
+ display_name,
+ username,
+ note,
+ avatar,
+ header,
+ locked,
+ bot,
+ discoverable,
+ source,
+ fields_attributes,
+ } = context.req.valid("json");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const {
- display_name,
- username,
- note,
- avatar,
- header,
- locked,
- bot,
- discoverable,
- source,
- fields_attributes,
- } = context.req.valid("json");
+ const self = user.data;
- const self = user.data;
-
- const sanitizedDisplayName = await sanitizedHtmlStrip(
- display_name ?? "",
- );
-
- if (display_name) {
- self.displayName = sanitizedDisplayName;
- }
-
- if (note && self.source) {
- self.source.note = note;
- self.note = await contentToHtml({
- "text/markdown": {
- content: note,
- remote: false,
- },
- });
- }
-
- if (source?.privacy) {
- self.source.privacy = source.privacy;
- }
-
- if (source?.sensitive) {
- self.source.sensitive = source.sensitive;
- }
-
- if (source?.language) {
- self.source.language = source.language;
- }
-
- if (username) {
- // Check if username is already taken
- const existingUser = await User.fromSql(
- and(isNull(Users.instanceId), eq(Users.username, username)),
+ const sanitizedDisplayName = await sanitizedHtmlStrip(
+ display_name ?? "",
);
- if (existingUser) {
- throw new ApiError(422, "Username is already taken");
+ if (display_name) {
+ self.displayName = sanitizedDisplayName;
}
- self.username = username;
- }
-
- if (avatar) {
- if (avatar instanceof File) {
- if (user.avatar) {
- await user.avatar.updateFromFile(avatar);
- } else {
- user.avatar = await Media.fromFile(avatar);
- }
- } else if (user.avatar) {
- await user.avatar.updateFromUrl(avatar);
- } else {
- user.avatar = await Media.fromUrl(avatar);
- }
- }
-
- if (header) {
- if (header instanceof File) {
- if (user.header) {
- await user.header.updateFromFile(header);
- } else {
- user.header = await Media.fromFile(header);
- }
- } else if (user.header) {
- await user.header.updateFromUrl(header);
- } else {
- user.header = await Media.fromUrl(header);
- }
- }
-
- if (locked) {
- self.isLocked = locked;
- }
-
- if (bot) {
- self.isBot = bot;
- }
-
- if (discoverable) {
- self.isDiscoverable = discoverable;
- }
-
- const fieldEmojis: Emoji[] = [];
-
- if (fields_attributes) {
- self.fields = [];
- self.source.fields = [];
- for (const field of fields_attributes) {
- // Can be Markdown or plaintext, also has emojis
- const parsedName = await contentToHtml(
- {
- "text/markdown": {
- content: field.name,
- remote: false,
- },
- },
- undefined,
- true,
- );
-
- const parsedValue = await contentToHtml(
- {
- "text/markdown": {
- content: field.value,
- remote: false,
- },
- },
- undefined,
- true,
- );
-
- // Parse emojis
- const nameEmojis = await Emoji.parseFromText(parsedName);
- const valueEmojis = await Emoji.parseFromText(parsedValue);
-
- fieldEmojis.push(...nameEmojis, ...valueEmojis);
-
- // Replace fields
- self.fields.push({
- key: {
- "text/html": {
- content: parsedName,
- remote: false,
- },
- },
- value: {
- "text/html": {
- content: parsedValue,
- remote: false,
- },
+ if (note && self.source) {
+ self.source.note = note;
+ self.note = await contentToHtml({
+ "text/markdown": {
+ content: note,
+ remote: false,
},
});
-
- self.source.fields.push({
- name: field.name,
- value: field.value,
- verified_at: null,
- });
}
- }
- // Parse emojis
- const displaynameEmojis =
- await Emoji.parseFromText(sanitizedDisplayName);
- const noteEmojis = await Emoji.parseFromText(self.note);
+ if (source?.privacy) {
+ self.source.privacy = source.privacy;
+ }
- const emojis = mergeAndDeduplicate(
- displaynameEmojis,
- noteEmojis,
- fieldEmojis,
- );
+ if (source?.sensitive) {
+ self.source.sensitive = source.sensitive;
+ }
- // Connect emojis, if any
- // Do it before updating user, so that federation takes that into account
- await user.updateEmojis(emojis);
- await user.update({
- displayName: self.displayName,
- username: self.username,
- note: self.note,
- avatar: self.avatar,
- avatarId: user.avatar?.id,
- header: self.header,
- headerId: user.header?.id,
- fields: self.fields,
- isLocked: self.isLocked,
- isBot: self.isBot,
- isDiscoverable: self.isDiscoverable,
- source: self.source || undefined,
- });
+ if (source?.language) {
+ self.source.language = source.language;
+ }
- const output = await User.fromId(self.id);
+ if (username) {
+ // Check if username is already taken
+ const existingUser = await User.fromSql(
+ and(isNull(Users.instanceId), eq(Users.username, username)),
+ );
- if (!output) {
- throw new ApiError(500, "Couldn't edit user");
- }
+ if (existingUser) {
+ throw new ApiError(422, "Username is already taken");
+ }
- return context.json(output.toApi(), 200);
- }),
+ self.username = username;
+ }
+
+ if (avatar) {
+ if (avatar instanceof File) {
+ if (user.avatar) {
+ await user.avatar.updateFromFile(avatar);
+ } else {
+ user.avatar = await Media.fromFile(avatar);
+ }
+ } else if (user.avatar) {
+ await user.avatar.updateFromUrl(avatar);
+ } else {
+ user.avatar = await Media.fromUrl(avatar);
+ }
+ }
+
+ if (header) {
+ if (header instanceof File) {
+ if (user.header) {
+ await user.header.updateFromFile(header);
+ } else {
+ user.header = await Media.fromFile(header);
+ }
+ } else if (user.header) {
+ await user.header.updateFromUrl(header);
+ } else {
+ user.header = await Media.fromUrl(header);
+ }
+ }
+
+ if (locked) {
+ self.isLocked = locked;
+ }
+
+ if (bot) {
+ self.isBot = bot;
+ }
+
+ if (discoverable) {
+ self.isDiscoverable = discoverable;
+ }
+
+ const fieldEmojis: Emoji[] = [];
+
+ if (fields_attributes) {
+ self.fields = [];
+ self.source.fields = [];
+ for (const field of fields_attributes) {
+ // Can be Markdown or plaintext, also has emojis
+ const parsedName = await contentToHtml(
+ {
+ "text/markdown": {
+ content: field.name,
+ remote: false,
+ },
+ },
+ undefined,
+ true,
+ );
+
+ const parsedValue = await contentToHtml(
+ {
+ "text/markdown": {
+ content: field.value,
+ remote: false,
+ },
+ },
+ undefined,
+ true,
+ );
+
+ // Parse emojis
+ const nameEmojis = await Emoji.parseFromText(parsedName);
+ const valueEmojis = await Emoji.parseFromText(parsedValue);
+
+ fieldEmojis.push(...nameEmojis, ...valueEmojis);
+
+ // Replace fields
+ self.fields.push({
+ key: {
+ "text/html": {
+ content: parsedName,
+ remote: false,
+ },
+ },
+ value: {
+ "text/html": {
+ content: parsedValue,
+ remote: false,
+ },
+ },
+ });
+
+ self.source.fields.push({
+ name: field.name,
+ value: field.value,
+ verified_at: null,
+ });
+ }
+ }
+
+ // Parse emojis
+ const displaynameEmojis =
+ await Emoji.parseFromText(sanitizedDisplayName);
+ const noteEmojis = await Emoji.parseFromText(self.note);
+
+ const emojis = mergeAndDeduplicate(
+ displaynameEmojis,
+ noteEmojis,
+ fieldEmojis,
+ );
+
+ // Connect emojis, if any
+ // Do it before updating user, so that federation takes that into account
+ await user.updateEmojis(emojis);
+ await user.update({
+ displayName: self.displayName,
+ username: self.username,
+ note: self.note,
+ avatar: self.avatar,
+ avatarId: user.avatar?.id,
+ header: self.header,
+ headerId: user.header?.id,
+ fields: self.fields,
+ isLocked: self.isLocked,
+ isBot: self.isBot,
+ isDiscoverable: self.isDiscoverable,
+ source: self.source || undefined,
+ });
+
+ const output = await User.fromId(self.id);
+
+ if (!output) {
+ throw new ApiError(500, "Couldn't edit user");
+ }
+
+ return context.json(output.toApi(), 200);
+ },
+ ),
);
diff --git a/api/api/v1/accounts/verify_credentials/index.ts b/api/api/v1/accounts/verify_credentials/index.ts
index 5278d175..e597d03c 100644
--- a/api/api/v1/accounts/verify_credentials/index.ts
+++ b/api/api/v1/accounts/verify_credentials/index.ts
@@ -1,44 +1,43 @@
import { apiRoute, auth } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
import { Account } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/accounts/verify_credentials",
- summary: "Verify account credentials",
- description: "Test to make sure that the user token works.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/accounts/#verify_credentials",
- },
- tags: ["Accounts"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/accounts/verify_credentials",
+ describeRoute({
+ summary: "Verify account credentials",
+ description: "Test to make sure that the user token works.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/accounts/#verify_credentials",
+ },
+ tags: ["Accounts"],
+ responses: {
+ 200: {
+ // TODO: Implement CredentialAccount
+ description:
+ "Note the extra source property, which is not visible on accounts other than your own. Also note that plain-text is used within source and HTML is used for their corresponding properties such as note and fields.",
+ content: {
+ "application/json": {
+ schema: resolver(Account),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
scopes: ["read:accounts"],
}),
- ] as const,
- responses: {
- 200: {
- // TODO: Implement CredentialAccount
- description:
- "Note the extra source property, which is not visible on accounts other than your own. Also note that plain-text is used within source and HTML is used for their corresponding properties such as note and fields.",
- content: {
- "application/json": {
- schema: Account,
- },
- },
+ (context) => {
+ // TODO: Add checks for disabled/unverified accounts
+ const { user } = context.get("auth");
+
+ return context.json(user.toApi(true), 200);
},
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, (context) => {
- // TODO: Add checks for disabled/unverified accounts
- const { user } = context.get("auth");
-
- return context.json(user.toApi(true), 200);
- }),
+ ),
);
diff --git a/api/api/v1/apps/index.ts b/api/api/v1/apps/index.ts
index 6cc767cf..aeb097f6 100644
--- a/api/api/v1/apps/index.ts
+++ b/api/api/v1/apps/index.ts
@@ -1,80 +1,80 @@
-import { apiRoute, jsonOrForm } from "@/api";
+import { apiRoute, handleZodError, jsonOrForm } from "@/api";
import { randomString } from "@/math";
-import { createRoute, z } from "@hono/zod-openapi";
import {
Application as ApplicationSchema,
CredentialApplication as CredentialApplicationSchema,
} from "@versia/client/schemas";
import { Application } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit";
-const route = createRoute({
- method: "post",
- path: "/api/v1/apps",
- summary: "Create an application",
- description: "Create a new application to obtain OAuth2 credentials.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/apps/#create",
- },
- tags: ["Apps"],
- middleware: [jsonOrForm(), rateLimit(4)],
- request: {
- body: {
- content: {
- "application/json": {
- schema: z.object({
- client_name: ApplicationSchema.shape.name,
- redirect_uris: ApplicationSchema.shape.redirect_uris.or(
- ApplicationSchema.shape.redirect_uri.transform(
- (u) => u.split("\n"),
- ),
- ),
- scopes: z
- .string()
- .default("read")
- .transform((s) => s.split(" "))
- .openapi({
- description: "Space separated list of scopes.",
- }),
- // Allow empty websites because Traewelling decides to give an empty
- // value instead of not providing anything at all
- website: ApplicationSchema.shape.website
- .optional()
- .or(z.literal("").transform(() => undefined)),
- }),
- },
- },
- },
- },
- responses: {
- 200: {
- description:
- "Store the client_id and client_secret in your cache, as these will be used to obtain OAuth tokens.",
- content: {
- "application/json": {
- schema: CredentialApplicationSchema,
- },
- },
- },
- 422: ApiError.validationFailed().schema,
- },
-});
-
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { client_name, redirect_uris, scopes, website } =
- context.req.valid("json");
+ app.post(
+ "/api/v1/apps",
+ describeRoute({
+ summary: "Create an application",
+ description:
+ "Create a new application to obtain OAuth2 credentials.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/apps/#create",
+ },
+ tags: ["Apps"],
+ responses: {
+ 200: {
+ description:
+ "Store the client_id and client_secret in your cache, as these will be used to obtain OAuth tokens.",
+ content: {
+ "application/json": {
+ schema: resolver(CredentialApplicationSchema),
+ },
+ },
+ },
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ jsonOrForm(),
+ rateLimit(4),
+ validator(
+ "json",
+ z.object({
+ client_name: ApplicationSchema.shape.name,
+ redirect_uris: ApplicationSchema.shape.redirect_uris.or(
+ ApplicationSchema.shape.redirect_uri.transform((u) =>
+ u.split("\n"),
+ ),
+ ),
+ scopes: z
+ .string()
+ .default("read")
+ .transform((s) => s.split(" "))
+ .openapi({
+ description: "Space separated list of scopes.",
+ }),
+ // Allow empty websites because Traewelling decides to give an empty
+ // value instead of not providing anything at all
+ website: ApplicationSchema.shape.website
+ .optional()
+ .or(z.literal("").transform(() => undefined)),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { client_name, redirect_uris, scopes, website } =
+ context.req.valid("json");
- const app = await Application.insert({
- name: client_name,
- redirectUri: redirect_uris.join("\n"),
- scopes: scopes.join(" "),
- website,
- clientId: randomString(32, "base64url"),
- secret: randomString(64, "base64url"),
- });
+ const app = await Application.insert({
+ name: client_name,
+ redirectUri: redirect_uris.join("\n"),
+ scopes: scopes.join(" "),
+ website,
+ clientId: randomString(32, "base64url"),
+ secret: randomString(64, "base64url"),
+ });
- return context.json(app.toApiCredential(), 200);
- }),
+ return context.json(app.toApiCredential(), 200);
+ },
+ ),
);
diff --git a/api/api/v1/apps/verify_credentials/index.ts b/api/api/v1/apps/verify_credentials/index.ts
index ac66eb62..c531a3fe 100644
--- a/api/api/v1/apps/verify_credentials/index.ts
+++ b/api/api/v1/apps/verify_credentials/index.ts
@@ -1,52 +1,51 @@
import { apiRoute, auth } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
import { Application as ApplicationSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Application } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/apps/verify_credentials",
- summary: "Verify your app works",
- description: "Confirm that the app’s OAuth2 credentials work.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/apps/#verify_credentials",
- },
- tags: ["Apps"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/apps/verify_credentials",
+ describeRoute({
+ summary: "Verify your app works",
+ description: "Confirm that the app’s OAuth2 credentials work.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/apps/#verify_credentials",
+ },
+ tags: ["Apps"],
+ responses: {
+ 200: {
+ description:
+ "If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.",
+ content: {
+ "application/json": {
+ schema: resolver(ApplicationSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
permissions: [RolePermission.ManageOwnApps],
}),
- ] as const,
- responses: {
- 200: {
- description:
- "If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.",
- content: {
- "application/json": {
- schema: ApplicationSchema,
- },
- },
+ async (context) => {
+ const { token } = context.get("auth");
+
+ const application = await Application.getFromToken(
+ token.data.accessToken,
+ );
+
+ if (!application) {
+ throw ApiError.applicationNotFound();
+ }
+
+ return context.json(application.toApi(), 200);
},
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { token } = context.get("auth");
-
- const application = await Application.getFromToken(
- token.data.accessToken,
- );
-
- if (!application) {
- throw ApiError.applicationNotFound();
- }
-
- return context.json(application.toApi(), 200);
- }),
+ ),
);
diff --git a/api/api/v1/blocks/index.ts b/api/api/v1/blocks/index.ts
index e40f1b7c..976646e0 100644
--- a/api/api/v1/blocks/index.ts
+++ b/api/api/v1/blocks/index.ts
@@ -1,100 +1,110 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/blocks",
- summary: "View your blocks.",
- description: "View blocked users.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/blocks/#get",
- },
- tags: ["Blocks"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/blocks",
+ describeRoute({
+ summary: "View your blocks.",
+ description: "View blocked users.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/blocks/#get",
+ },
+ tags: ["Blocks"],
+ responses: {
+ 200: {
+ description: "List of blocked users",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(AccountSchema)),
+ },
+ },
+ headers: z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
scopes: ["read:blocks"],
permissions: [RolePermission.ManageOwnBlocks],
}),
- ] as const,
- request: {
- query: z.object({
- max_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: AccountSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
- description: "Maximum number of results to return.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "List of blocked users",
- content: {
- "application/json": {
- schema: z.array(AccountSchema),
- },
- },
- headers: z.object({
- link: z
- .string()
- .optional()
+ validator(
+ "query",
+ z.object({
+ max_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(80)
+ .default(40)
.openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
+ description: "Maximum number of results to return.",
}),
}),
+ handleZodError,
+ ),
+ async (context) => {
+ const { max_id, since_id, min_id, limit } =
+ context.req.valid("query");
+
+ const { user } = context.get("auth");
+
+ const { objects: blocks, link } = await Timeline.getUserTimeline(
+ and(
+ max_id ? lt(Users.id, max_id) : undefined,
+ since_id ? gte(Users.id, since_id) : undefined,
+ min_id ? gt(Users.id, min_id) : undefined,
+ sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
+ ),
+ limit,
+ new URL(context.req.url),
+ );
+
+ return context.json(
+ blocks.map((u) => u.toApi()),
+ 200,
+ {
+ Link: link,
+ },
+ );
},
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, since_id, min_id, limit } = context.req.valid("query");
-
- const { user } = context.get("auth");
-
- const { objects: blocks, link } = await Timeline.getUserTimeline(
- and(
- max_id ? lt(Users.id, max_id) : undefined,
- since_id ? gte(Users.id, since_id) : undefined,
- min_id ? gt(Users.id, min_id) : undefined,
- sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
- ),
- limit,
- new URL(context.req.url),
- );
-
- return context.json(
- blocks.map((u) => u.toApi()),
- 200,
- {
- Link: link,
- },
- );
- }),
+ ),
);
diff --git a/api/api/v1/challenges/index.ts b/api/api/v1/challenges/index.ts
index 9a27cd82..34bed792 100644
--- a/api/api/v1/challenges/index.ts
+++ b/api/api/v1/challenges/index.ts
@@ -1,55 +1,54 @@
import { apiRoute, auth } from "@/api";
import { generateChallenge } from "@/challenges";
-import { createRoute } from "@hono/zod-openapi";
import { Challenge } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "post",
- path: "/api/v1/challenges",
- summary: "Generate a challenge",
- description: "Generate a challenge to solve",
- tags: ["Challenges"],
- middleware: [
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/challenges",
+ describeRoute({
+ summary: "Generate a challenge",
+ description: "Generate a challenge to solve",
+ tags: ["Challenges"],
+ responses: {
+ 200: {
+ description: "Challenge",
+ content: {
+ "application/json": {
+ schema: resolver(Challenge),
+ },
+ },
+ },
+ 400: {
+ description: "Challenges are disabled",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ },
+ }),
auth({
auth: false,
}),
- ] as const,
- responses: {
- 200: {
- description: "Challenge",
- content: {
- "application/json": {
- schema: Challenge,
+ async (context) => {
+ if (!config.validation.challenges) {
+ throw new ApiError(400, "Challenges are disabled in config");
+ }
+
+ const result = await generateChallenge();
+
+ return context.json(
+ {
+ id: result.id,
+ ...result.challenge,
},
- },
+ 200,
+ );
},
- 400: {
- description: "Challenges are disabled",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- if (!config.validation.challenges) {
- throw new ApiError(400, "Challenges are disabled in config");
- }
-
- const result = await generateChallenge();
-
- return context.json(
- {
- id: result.id,
- ...result.challenge,
- },
- 200,
- );
- }),
+ ),
);
diff --git a/api/api/v1/custom_emojis/index.ts b/api/api/v1/custom_emojis/index.ts
index 61f1b437..84d47b4f 100644
--- a/api/api/v1/custom_emojis/index.ts
+++ b/api/api/v1/custom_emojis/index.ts
@@ -1,57 +1,58 @@
import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Emoji } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/custom_emojis",
- summary: "View all custom emoji",
- description: "Returns custom emojis that are available on the server.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/custom_emojis/#get",
- },
- tags: ["Emojis"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/custom_emojis",
+ describeRoute({
+ summary: "View all custom emoji",
+ description:
+ "Returns custom emojis that are available on the server.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/custom_emojis/#get",
+ },
+ tags: ["Emojis"],
+ responses: {
+ 200: {
+ description: "List of custom emojis",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(CustomEmojiSchema)),
+ },
+ },
+ },
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: false,
permissions: [RolePermission.ViewEmojis],
}),
- ] as const,
- responses: {
- 200: {
- description: "List of custom emojis",
- content: {
- "application/json": {
- schema: z.array(CustomEmojiSchema),
- },
- },
- },
- 422: ApiError.validationFailed().schema,
- },
-});
+ async (context) => {
+ const { user } = context.get("auth");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
-
- const emojis = await Emoji.manyFromSql(
- and(
- isNull(Emojis.instanceId),
- or(
- isNull(Emojis.ownerId),
- user ? eq(Emojis.ownerId, user.id) : undefined,
+ const emojis = await Emoji.manyFromSql(
+ and(
+ isNull(Emojis.instanceId),
+ or(
+ isNull(Emojis.ownerId),
+ user ? eq(Emojis.ownerId, user.id) : undefined,
+ ),
),
- ),
- );
+ );
- return context.json(
- emojis.map((emoji) => emoji.toApi()),
- 200,
- );
- }),
+ return context.json(
+ emojis.map((emoji) => emoji.toApi()),
+ 200,
+ );
+ },
+ ),
);
diff --git a/api/api/v1/emojis/:id/index.ts b/api/api/v1/emojis/:id/index.ts
deleted file mode 100644
index 2b65e4cf..00000000
--- a/api/api/v1/emojis/:id/index.ts
+++ /dev/null
@@ -1,276 +0,0 @@
-import { apiRoute, auth, jsonOrForm, withEmojiParam } from "@/api";
-import { mimeLookup } from "@/content_types";
-import { createRoute, z } from "@hono/zod-openapi";
-import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { ApiError } from "~/classes/errors/api-error";
-import { config } from "~/config.ts";
-
-const schema = z
- .object({
- shortcode: CustomEmojiSchema.shape.shortcode,
- element: z
- .string()
- .url()
- .transform((a) => new URL(a))
- .openapi({
- description: "Emoji image URL",
- })
- .or(
- z
- .instanceof(File)
- .openapi({
- description:
- "Emoji image encoded using multipart/form-data",
- })
- .refine(
- (v) => v.size <= config.validation.emojis.max_bytes,
- `Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
- ),
- ),
- category: CustomEmojiSchema.shape.category.optional(),
- alt: CustomEmojiSchema.shape.description.optional(),
- global: CustomEmojiSchema.shape.global.default(false),
- })
- .partial();
-
-const routeGet = createRoute({
- method: "get",
- path: "/api/v1/emojis/{id}",
- summary: "Get emoji",
- description: "Retrieves a custom emoji from database by ID.",
- tags: ["Emojis"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ViewEmojis],
- }),
- withEmojiParam,
- ] as const,
- request: {
- params: z.object({
- id: CustomEmojiSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Emoji",
- content: {
- "application/json": {
- schema: CustomEmojiSchema,
- },
- },
- },
- 404: {
- description: "Emoji not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-const routePatch = createRoute({
- method: "patch",
- path: "/api/v1/emojis/{id}",
- summary: "Modify emoji",
- description: "Edit image or metadata of an emoji.",
- tags: ["Emojis"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnEmojis,
- RolePermission.ViewEmojis,
- ],
- }),
- jsonOrForm(),
- withEmojiParam,
- ] as const,
- request: {
- params: z.object({
- id: CustomEmojiSchema.shape.id,
- }),
- body: {
- content: {
- "application/json": {
- schema,
- },
- "application/x-www-form-urlencoded": {
- schema,
- },
- "multipart/form-data": {
- schema,
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Emoji modified",
- content: {
- "application/json": {
- schema: CustomEmojiSchema,
- },
- },
- },
- 403: {
- description: "Insufficient permissions",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 404: {
- description: "Emoji not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-const routeDelete = createRoute({
- method: "delete",
- path: "/api/v1/emojis/{id}",
- summary: "Delete emoji",
- description: "Delete a custom emoji from the database.",
- tags: ["Emojis"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnEmojis,
- RolePermission.ViewEmojis,
- ],
- }),
- withEmojiParam,
- ] as const,
- request: {
- params: z.object({
- id: CustomEmojiSchema.shape.id,
- }),
- },
- responses: {
- 204: {
- description: "Emoji deleted",
- },
- 404: ApiError.emojiNotFound().schema,
- },
-});
-
-export default apiRoute((app) => {
- app.openapi(routeGet, (context) => {
- const { user } = context.get("auth");
- const emoji = context.get("emoji");
-
- // Don't leak non-global emojis to non-admins
- if (
- !user.hasPermission(RolePermission.ManageEmojis) &&
- emoji.data.ownerId !== user.data.id
- ) {
- throw ApiError.emojiNotFound();
- }
-
- return context.json(emoji.toApi(), 200);
- });
-
- app.openapi(routePatch, async (context) => {
- const { user } = context.get("auth");
- const emoji = context.get("emoji");
-
- // Check if user is admin
- if (
- !user.hasPermission(RolePermission.ManageEmojis) &&
- emoji.data.ownerId !== user.data.id
- ) {
- throw new ApiError(
- 403,
- "Cannot modify emoji not owned by you",
- `This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
- );
- }
-
- const {
- global: emojiGlobal,
- alt,
- category,
- element,
- shortcode,
- } = context.req.valid("json");
-
- if (!user.hasPermission(RolePermission.ManageEmojis) && emojiGlobal) {
- throw new ApiError(
- 401,
- "Missing permissions",
- `'${RolePermission.ManageEmojis}' permission is needed to upload global emojis`,
- );
- }
-
- if (element) {
- // Check if emoji is an image
- const contentType =
- element instanceof File
- ? element.type
- : await mimeLookup(element);
-
- if (!contentType.startsWith("image/")) {
- throw new ApiError(
- 422,
- "Invalid content type",
- `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
- );
- }
-
- if (element instanceof File) {
- await emoji.media.updateFromFile(element);
- } else {
- await emoji.media.updateFromUrl(element);
- }
- }
-
- if (alt) {
- await emoji.media.updateMetadata({
- description: alt,
- });
- }
-
- await emoji.update({
- shortcode,
- ownerId: emojiGlobal ? null : user.data.id,
- category,
- });
-
- return context.json(emoji.toApi(), 200);
- });
-
- app.openapi(routeDelete, async (context) => {
- const { user } = context.get("auth");
- const emoji = context.get("emoji");
-
- // Check if user is admin
- if (
- !user.hasPermission(RolePermission.ManageEmojis) &&
- emoji.data.ownerId !== user.data.id
- ) {
- throw new ApiError(
- 403,
- "Cannot delete emoji not owned by you",
- `This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
- );
- }
-
- await emoji.delete();
-
- return context.body(null, 204);
- });
-});
diff --git a/api/api/v1/emojis/:id/index.test.ts b/api/api/v1/emojis/[id]/index.test.ts
similarity index 100%
rename from api/api/v1/emojis/:id/index.test.ts
rename to api/api/v1/emojis/[id]/index.test.ts
diff --git a/api/api/v1/emojis/[id]/index.ts b/api/api/v1/emojis/[id]/index.ts
new file mode 100644
index 00000000..1981251d
--- /dev/null
+++ b/api/api/v1/emojis/[id]/index.ts
@@ -0,0 +1,258 @@
+import {
+ apiRoute,
+ auth,
+ handleZodError,
+ jsonOrForm,
+ withEmojiParam,
+} from "@/api";
+import { mimeLookup } from "@/content_types";
+import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+import { config } from "~/config.ts";
+
+export default apiRoute((app) => {
+ app.get(
+ "/api/v1/emojis/:id",
+ describeRoute({
+ summary: "Get emoji",
+ description: "Retrieves a custom emoji from database by ID.",
+ tags: ["Emojis"],
+ responses: {
+ 200: {
+ description: "Emoji",
+ content: {
+ "application/json": {
+ schema: resolver(CustomEmojiSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Emoji not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ViewEmojis],
+ }),
+ withEmojiParam,
+ (context) => {
+ const { user } = context.get("auth");
+ const emoji = context.get("emoji");
+
+ // Don't leak non-global emojis to non-admins
+ if (
+ !user.hasPermission(RolePermission.ManageEmojis) &&
+ emoji.data.ownerId !== user.data.id
+ ) {
+ throw ApiError.emojiNotFound();
+ }
+
+ return context.json(emoji.toApi(), 200);
+ },
+ );
+
+ app.patch(
+ "/api/v1/emojis/:id",
+ describeRoute({
+ summary: "Modify emoji",
+ description: "Edit image or metadata of an emoji.",
+ tags: ["Emojis"],
+ responses: {
+ 200: {
+ description: "Emoji modified",
+ content: {
+ "application/json": {
+ schema: resolver(CustomEmojiSchema),
+ },
+ },
+ },
+ 403: {
+ description: "Insufficient permissions",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Emoji not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnEmojis,
+ RolePermission.ViewEmojis,
+ ],
+ }),
+ jsonOrForm(),
+ withEmojiParam,
+ validator(
+ "json",
+ z
+ .object({
+ shortcode: CustomEmojiSchema.shape.shortcode,
+ element: z
+ .string()
+ .url()
+ .transform((a) => new URL(a))
+ .openapi({
+ description: "Emoji image URL",
+ })
+ .or(
+ z
+ .instanceof(File)
+ .openapi({
+ description:
+ "Emoji image encoded using multipart/form-data",
+ })
+ .refine(
+ (v) =>
+ v.size <=
+ config.validation.emojis.max_bytes,
+ `Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
+ ),
+ ),
+ category: CustomEmojiSchema.shape.category.optional(),
+ alt: CustomEmojiSchema.shape.description.optional(),
+ global: CustomEmojiSchema.shape.global.default(false),
+ })
+ .partial(),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const emoji = context.get("emoji");
+
+ // Check if user is admin
+ if (
+ !user.hasPermission(RolePermission.ManageEmojis) &&
+ emoji.data.ownerId !== user.data.id
+ ) {
+ throw new ApiError(
+ 403,
+ "Cannot modify emoji not owned by you",
+ `This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
+ );
+ }
+
+ const {
+ global: emojiGlobal,
+ alt,
+ category,
+ element,
+ shortcode,
+ } = context.req.valid("json");
+
+ if (
+ !user.hasPermission(RolePermission.ManageEmojis) &&
+ emojiGlobal
+ ) {
+ throw new ApiError(
+ 401,
+ "Missing permissions",
+ `'${RolePermission.ManageEmojis}' permission is needed to upload global emojis`,
+ );
+ }
+
+ if (element) {
+ // Check if emoji is an image
+ const contentType =
+ element instanceof File
+ ? element.type
+ : await mimeLookup(element);
+
+ if (!contentType.startsWith("image/")) {
+ throw new ApiError(
+ 422,
+ "Invalid content type",
+ `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
+ );
+ }
+
+ if (element instanceof File) {
+ await emoji.media.updateFromFile(element);
+ } else {
+ await emoji.media.updateFromUrl(element);
+ }
+ }
+
+ if (alt) {
+ await emoji.media.updateMetadata({
+ description: alt,
+ });
+ }
+
+ await emoji.update({
+ shortcode,
+ ownerId: emojiGlobal ? null : user.data.id,
+ category,
+ });
+
+ return context.json(emoji.toApi(), 200);
+ },
+ );
+
+ app.delete(
+ "/api/v1/emojis/{id}",
+ describeRoute({
+ summary: "Delete emoji",
+ description: "Delete a custom emoji from the database.",
+ tags: ["Emojis"],
+ responses: {
+ 204: {
+ description: "Emoji deleted",
+ },
+ 404: ApiError.emojiNotFound().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnEmojis,
+ RolePermission.ViewEmojis,
+ ],
+ }),
+ withEmojiParam,
+ async (context) => {
+ const { user } = context.get("auth");
+ const emoji = context.get("emoji");
+
+ // Check if user is admin
+ if (
+ !user.hasPermission(RolePermission.ManageEmojis) &&
+ emoji.data.ownerId !== user.data.id
+ ) {
+ throw new ApiError(
+ 403,
+ "Cannot delete emoji not owned by you",
+ `This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
+ );
+ }
+
+ await emoji.delete();
+
+ return context.body(null, 204);
+ },
+ );
+});
diff --git a/api/api/v1/emojis/index.ts b/api/api/v1/emojis/index.ts
index cc9e8e61..57c198b2 100644
--- a/api/api/v1/emojis/index.ts
+++ b/api/api/v1/emojis/index.ts
@@ -1,47 +1,36 @@
-import { apiRoute, auth, jsonOrForm } from "@/api";
+import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { mimeLookup } from "@/content_types";
-import { createRoute, z } from "@hono/zod-openapi";
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Emoji, Media } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
-const schema = z.object({
- shortcode: CustomEmojiSchema.shape.shortcode,
- element: z
- .string()
- .url()
- .transform((a) => new URL(a))
- .openapi({
- description: "Emoji image URL",
- })
- .or(
- z
- .instanceof(File)
- .openapi({
- description:
- "Emoji image encoded using multipart/form-data",
- })
- .refine(
- (v) => v.size <= config.validation.emojis.max_bytes,
- `Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
- ),
- ),
- category: CustomEmojiSchema.shape.category.optional(),
- alt: CustomEmojiSchema.shape.description.optional(),
- global: CustomEmojiSchema.shape.global.default(false),
-});
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/emojis",
- summary: "Upload emoji",
- description: "Upload a new emoji to the server.",
- tags: ["Emojis"],
- middleware: [
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/emojis",
+ describeRoute({
+ summary: "Upload emoji",
+ description: "Upload a new emoji to the server.",
+ tags: ["Emojis"],
+ responses: {
+ 201: {
+ description: "Uploaded emoji",
+ content: {
+ "application/json": {
+ schema: resolver(CustomEmojiSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
permissions: [
@@ -50,96 +39,99 @@ const route = createRoute({
],
}),
jsonOrForm(),
- ] as const,
- request: {
- body: {
- content: {
- "application/json": {
- schema,
- },
- "multipart/form-data": {
- schema,
- },
- "application/x-www-form-urlencoded": {
- schema,
- },
- },
+ validator(
+ "json",
+ z.object({
+ shortcode: CustomEmojiSchema.shape.shortcode,
+ element: z
+ .string()
+ .url()
+ .transform((a) => new URL(a))
+ .openapi({
+ description: "Emoji image URL",
+ })
+ .or(
+ z
+ .instanceof(File)
+ .openapi({
+ description:
+ "Emoji image encoded using multipart/form-data",
+ })
+ .refine(
+ (v) =>
+ v.size <=
+ config.validation.emojis.max_bytes,
+ `Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
+ ),
+ ),
+ category: CustomEmojiSchema.shape.category.optional(),
+ alt: CustomEmojiSchema.shape.description.optional(),
+ global: CustomEmojiSchema.shape.global.default(false),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { shortcode, element, alt, global, category } =
+ context.req.valid("json");
+ const { user } = context.get("auth");
+
+ if (!user.hasPermission(RolePermission.ManageEmojis) && global) {
+ throw new ApiError(
+ 401,
+ "Missing permissions",
+ `Only users with the '${RolePermission.ManageEmojis}' permission can upload global emojis`,
+ );
+ }
+
+ // Check if emoji already exists
+ const existing = await Emoji.fromSql(
+ and(
+ eq(Emojis.shortcode, shortcode),
+ isNull(Emojis.instanceId),
+ or(eq(Emojis.ownerId, user.id), isNull(Emojis.ownerId)),
+ ),
+ );
+
+ if (existing) {
+ throw new ApiError(
+ 422,
+ "Emoji already exists",
+ `An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
+ );
+ }
+
+ // Check of emoji is an image
+ const contentType =
+ element instanceof File
+ ? element.type
+ : await mimeLookup(element);
+
+ if (!contentType.startsWith("image/")) {
+ throw new ApiError(
+ 422,
+ "Invalid content type",
+ `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
+ );
+ }
+
+ const media =
+ element instanceof File
+ ? await Media.fromFile(element, {
+ description: alt ?? undefined,
+ })
+ : await Media.fromUrl(element, {
+ description: alt ?? undefined,
+ });
+
+ const emoji = await Emoji.insert({
+ shortcode,
+ mediaId: media.id,
+ visibleInPicker: true,
+ ownerId: global ? null : user.id,
+ category,
+ });
+
+ return context.json(emoji.toApi(), 201);
},
- },
- responses: {
- 201: {
- description: "Uploaded emoji",
- content: {
- "application/json": {
- schema: CustomEmojiSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { shortcode, element, alt, global, category } =
- context.req.valid("json");
- const { user } = context.get("auth");
-
- if (!user.hasPermission(RolePermission.ManageEmojis) && global) {
- throw new ApiError(
- 401,
- "Missing permissions",
- `Only users with the '${RolePermission.ManageEmojis}' permission can upload global emojis`,
- );
- }
-
- // Check if emoji already exists
- const existing = await Emoji.fromSql(
- and(
- eq(Emojis.shortcode, shortcode),
- isNull(Emojis.instanceId),
- or(eq(Emojis.ownerId, user.id), isNull(Emojis.ownerId)),
- ),
- );
-
- if (existing) {
- throw new ApiError(
- 422,
- "Emoji already exists",
- `An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
- );
- }
-
- // Check of emoji is an image
- const contentType =
- element instanceof File ? element.type : await mimeLookup(element);
-
- if (!contentType.startsWith("image/")) {
- throw new ApiError(
- 422,
- "Invalid content type",
- `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
- );
- }
-
- const media =
- element instanceof File
- ? await Media.fromFile(element, {
- description: alt ?? undefined,
- })
- : await Media.fromUrl(element, {
- description: alt ?? undefined,
- });
-
- const emoji = await Emoji.insert({
- shortcode,
- mediaId: media.id,
- visibleInPicker: true,
- ownerId: global ? null : user.id,
- category,
- });
-
- return context.json(emoji.toApi(), 201);
- }),
+ ),
);
diff --git a/api/api/v1/favourites/index.ts b/api/api/v1/favourites/index.ts
index 37f17dd2..a1ad14e9 100644
--- a/api/api/v1/favourites/index.ts
+++ b/api/api/v1/favourites/index.ts
@@ -1,100 +1,111 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/favourites",
- summary: "View favourited statuses",
- description: "Statuses the user has favourited.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/favourites/#get",
- },
- tags: ["Favourites"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/favourites",
+ describeRoute({
+ summary: "View favourited statuses",
+ description: "Statuses the user has favourited.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/favourites/#get",
+ },
+ tags: ["Favourites"],
+ responses: {
+ 200: {
+ description: "List of favourited statuses",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(StatusSchema)),
+ },
+ },
+ headers: z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
permissions: [RolePermission.ManageOwnLikes],
}),
- ] as const,
- request: {
- query: z.object({
- max_id: StatusSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: StatusSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: StatusSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
- description: "Maximum number of results to return.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "List of favourited statuses",
- content: {
- "application/json": {
- schema: z.array(StatusSchema),
- },
- },
- headers: z.object({
- link: z
- .string()
- .optional()
+ validator(
+ "query",
+ z.object({
+ max_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(80)
+ .default(40)
.openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
+ description: "Maximum number of results to return.",
}),
}),
+ handleZodError,
+ ),
+ async (context) => {
+ const { max_id, since_id, min_id, limit } =
+ context.req.valid("query");
+
+ const { user } = context.get("auth");
+
+ const { objects: favourites, link } =
+ await Timeline.getNoteTimeline(
+ and(
+ max_id ? lt(Notes.id, max_id) : undefined,
+ since_id ? gte(Notes.id, since_id) : undefined,
+ min_id ? gt(Notes.id, min_id) : undefined,
+ sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
+ ),
+ limit,
+ new URL(context.req.url),
+ user?.id,
+ );
+
+ return context.json(
+ await Promise.all(favourites.map((note) => note.toApi(user))),
+ 200,
+ {
+ Link: link,
+ },
+ );
},
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, since_id, min_id, limit } = context.req.valid("query");
-
- const { user } = context.get("auth");
-
- const { objects: favourites, link } = await Timeline.getNoteTimeline(
- and(
- max_id ? lt(Notes.id, max_id) : undefined,
- since_id ? gte(Notes.id, since_id) : undefined,
- min_id ? gt(Notes.id, min_id) : undefined,
- sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
- ),
- limit,
- new URL(context.req.url),
- user?.id,
- );
-
- return context.json(
- await Promise.all(favourites.map((note) => note.toApi(user))),
- 200,
- {
- Link: link,
- },
- );
- }),
+ ),
);
diff --git a/api/api/v1/follow_requests/:account_id/authorize.ts b/api/api/v1/follow_requests/:account_id/authorize.ts
deleted file mode 100644
index f0df124f..00000000
--- a/api/api/v1/follow_requests/:account_id/authorize.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship, User } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/follow_requests/{account_id}/authorize",
- summary: "Accept follow request",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/follow_requests/#accept",
- },
- tags: ["Follows"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnFollows],
- }),
- ] as const,
- request: {
- params: z.object({
- account_id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description:
- "Your Relationship with this account should be updated so that you are followed_by this account.",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
-
- const { account_id } = context.req.valid("param");
-
- const account = await User.fromId(account_id);
-
- if (!account) {
- throw ApiError.accountNotFound();
- }
-
- const oppositeRelationship = await Relationship.fromOwnerAndSubject(
- account,
- user,
- );
-
- await oppositeRelationship.update({
- requested: false,
- following: true,
- });
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- account,
- );
-
- // Check if accepting remote follow
- if (account.isRemote()) {
- // Federate follow accept
- await user.sendFollowAccept(account);
- }
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/follow_requests/:account_id/reject.ts b/api/api/v1/follow_requests/:account_id/reject.ts
deleted file mode 100644
index 33e17cc9..00000000
--- a/api/api/v1/follow_requests/:account_id/reject.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Relationship as RelationshipSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Relationship, User } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/follow_requests/{account_id}/reject",
- summary: "Reject follow request",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/follow_requests/#reject",
- },
- tags: ["Follows"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnFollows],
- }),
- ] as const,
- request: {
- params: z.object({
- account_id: AccountSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description:
- "Your Relationship with this account should be unchanged.",
- content: {
- "application/json": {
- schema: RelationshipSchema,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
-
- const { account_id } = context.req.valid("param");
-
- const account = await User.fromId(account_id);
-
- if (!account) {
- throw ApiError.accountNotFound();
- }
-
- const oppositeRelationship = await Relationship.fromOwnerAndSubject(
- account,
- user,
- );
-
- await oppositeRelationship.update({
- requested: false,
- following: false,
- });
-
- const foundRelationship = await Relationship.fromOwnerAndSubject(
- user,
- account,
- );
-
- // Check if rejecting remote follow
- if (account.isRemote()) {
- // Federate follow reject
- await user.sendFollowReject(account);
- }
-
- return context.json(foundRelationship.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/follow_requests/[account_id]/authorize.ts b/api/api/v1/follow_requests/[account_id]/authorize.ts
new file mode 100644
index 00000000..901e261c
--- /dev/null
+++ b/api/api/v1/follow_requests/[account_id]/authorize.ts
@@ -0,0 +1,82 @@
+import { apiRoute, auth, handleZodError } from "@/api";
+import {
+ Account as AccountSchema,
+ Relationship as RelationshipSchema,
+} from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship, User } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/follow_requests/:account_id/authorize",
+ describeRoute({
+ summary: "Accept follow request",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/follow_requests/#accept",
+ },
+ tags: ["Follows"],
+ responses: {
+ 200: {
+ description:
+ "Your Relationship with this account should be updated so that you are followed_by this account.",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnFollows],
+ }),
+ validator(
+ "param",
+ z.object({
+ account_id: AccountSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { account_id } = context.req.valid("param");
+
+ const account = await User.fromId(account_id);
+
+ if (!account) {
+ throw ApiError.accountNotFound();
+ }
+
+ const oppositeRelationship = await Relationship.fromOwnerAndSubject(
+ account,
+ user,
+ );
+
+ await oppositeRelationship.update({
+ requested: false,
+ following: true,
+ });
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ account,
+ );
+
+ // Check if accepting remote follow
+ if (account.isRemote()) {
+ // Federate follow accept
+ await user.sendFollowAccept(account);
+ }
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/follow_requests/[account_id]/reject.ts b/api/api/v1/follow_requests/[account_id]/reject.ts
new file mode 100644
index 00000000..1ee1b858
--- /dev/null
+++ b/api/api/v1/follow_requests/[account_id]/reject.ts
@@ -0,0 +1,83 @@
+import { apiRoute, auth, handleZodError } from "@/api";
+import {
+ Account as AccountSchema,
+ Relationship as RelationshipSchema,
+} from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Relationship, User } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/follow_requests/:account_id/reject",
+ describeRoute({
+ summary: "Reject follow request",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/follow_requests/#reject",
+ },
+ tags: ["Follows"],
+ responses: {
+ 200: {
+ description:
+ "Your Relationship with this account should be unchanged.",
+ content: {
+ "application/json": {
+ schema: resolver(RelationshipSchema),
+ },
+ },
+ },
+ 404: ApiError.accountNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnFollows],
+ }),
+ validator(
+ "param",
+ z.object({
+ account_id: AccountSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+
+ const { account_id } = context.req.valid("param");
+
+ const account = await User.fromId(account_id);
+
+ if (!account) {
+ throw ApiError.accountNotFound();
+ }
+
+ const oppositeRelationship = await Relationship.fromOwnerAndSubject(
+ account,
+ user,
+ );
+
+ await oppositeRelationship.update({
+ requested: false,
+ following: false,
+ });
+
+ const foundRelationship = await Relationship.fromOwnerAndSubject(
+ user,
+ account,
+ );
+
+ // Check if rejecting remote follow
+ if (account.isRemote()) {
+ // Federate follow reject
+ await user.sendFollowReject(account);
+ }
+
+ return context.json(foundRelationship.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/follow_requests/index.ts b/api/api/v1/follow_requests/index.ts
index 56a20a5e..963e9295 100644
--- a/api/api/v1/follow_requests/index.ts
+++ b/api/api/v1/follow_requests/index.ts
@@ -1,101 +1,112 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/follow_requests",
- summary: "View pending follow requests",
- description: "Get a list of follow requests that the user has received.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/follow_requests/#get",
- },
- tags: ["Follows"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/follow_requests",
+ describeRoute({
+ summary: "View pending follow requests",
+ description:
+ "Get a list of follow requests that the user has received.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/follow_requests/#get",
+ },
+ tags: ["Follows"],
+ responses: {
+ 200: {
+ description:
+ "List of accounts that have requested to follow the user",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(AccountSchema)),
+ },
+ },
+ headers: z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
permissions: [RolePermission.ManageOwnFollows],
}),
- ] as const,
- request: {
- query: z.object({
- max_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: AccountSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
- description: "Maximum number of results to return.",
- }),
- }),
- },
- responses: {
- 200: {
- description:
- "List of accounts that have requested to follow the user",
- content: {
- "application/json": {
- schema: z.array(AccountSchema),
- },
- },
- headers: z.object({
- link: z
- .string()
- .optional()
+ validator(
+ "query",
+ z.object({
+ max_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(80)
+ .default(40)
.openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
+ description: "Maximum number of results to return.",
}),
}),
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
+ handleZodError,
+ ),
+ async (context) => {
+ const { max_id, since_id, min_id, limit } =
+ context.req.valid("query");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, since_id, min_id, limit } = context.req.valid("query");
+ const { user } = context.get("auth");
- const { user } = context.get("auth");
+ const { objects: followRequests, link } =
+ await Timeline.getUserTimeline(
+ and(
+ max_id ? lt(Users.id, max_id) : undefined,
+ since_id ? gte(Users.id, since_id) : undefined,
+ min_id ? gt(Users.id, min_id) : undefined,
+ sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
+ ),
+ limit,
+ new URL(context.req.url),
+ );
- const { objects: followRequests, link } =
- await Timeline.getUserTimeline(
- and(
- max_id ? lt(Users.id, max_id) : undefined,
- since_id ? gte(Users.id, since_id) : undefined,
- min_id ? gt(Users.id, min_id) : undefined,
- sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
- ),
- limit,
- new URL(context.req.url),
+ return context.json(
+ followRequests.map((u) => u.toApi()),
+ 200,
+ {
+ Link: link,
+ },
);
-
- return context.json(
- followRequests.map((u) => u.toApi()),
- 200,
- {
- Link: link,
- },
- );
- }),
+ },
+ ),
);
diff --git a/api/api/v1/frontend/config/index.ts b/api/api/v1/frontend/config/index.ts
index 34a33fa9..df9bdc28 100644
--- a/api/api/v1/frontend/config/index.ts
+++ b/api/api/v1/frontend/config/index.ts
@@ -1,25 +1,29 @@
import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { z } from "zod";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "get",
- path: "/api/v1/frontend/config",
- summary: "Get frontend config",
- responses: {
- 200: {
- description: "Frontend config",
- content: {
- "application/json": {
- schema: z.record(z.string(), z.any()).default({}),
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/frontend/config",
+ describeRoute({
+ summary: "Get frontend config",
+ responses: {
+ 200: {
+ description: "Frontend config",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.record(z.string(), z.any()).default({}),
+ ),
+ },
+ },
},
},
+ }),
+ (context) => {
+ return context.json(config.frontend.settings, 200);
},
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, (context) => {
- return context.json(config.frontend.settings, 200);
- }),
+ ),
);
diff --git a/api/api/v1/instance/extended_description.ts b/api/api/v1/instance/extended_description.ts
index 0f060e1f..0a98af87 100644
--- a/api/api/v1/instance/extended_description.ts
+++ b/api/api/v1/instance/extended_description.ts
@@ -1,46 +1,47 @@
import { apiRoute } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
import { ExtendedDescription as ExtendedDescriptionSchema } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { markdownParse } from "~/classes/functions/status";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "get",
- path: "/api/v1/instance/extended_description",
- summary: "View extended description",
- description: "Obtain an extended description of this server",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/instance/#extended_description",
- },
- tags: ["Instance"],
- responses: {
- 200: {
- description: "Server extended description",
- content: {
- "application/json": {
- schema: ExtendedDescriptionSchema,
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/instance/extended_description",
+ describeRoute({
+ summary: "View extended description",
+ description: "Obtain an extended description of this server",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/instance/#extended_description",
+ },
+ tags: ["Instance"],
+ responses: {
+ 200: {
+ description: "Server extended description",
+ content: {
+ "application/json": {
+ schema: resolver(ExtendedDescriptionSchema),
+ },
+ },
},
},
+ }),
+ async (context) => {
+ const content = await markdownParse(
+ config.instance.extended_description_path?.content ??
+ "This is a [Versia](https://versia.pub) server with the default extended description.",
+ );
+
+ return context.json(
+ {
+ updated_at: new Date(
+ config.instance.extended_description_path?.file
+ .lastModified ?? 0,
+ ).toISOString(),
+ content,
+ },
+ 200,
+ );
},
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const content = await markdownParse(
- config.instance.extended_description_path?.content ??
- "This is a [Versia](https://versia.pub) server with the default extended description.",
- );
-
- return context.json(
- {
- updated_at: new Date(
- config.instance.extended_description_path?.file
- .lastModified ?? 0,
- ).toISOString(),
- content,
- },
- 200,
- );
- }),
+ ),
);
diff --git a/api/api/v1/instance/index.ts b/api/api/v1/instance/index.ts
index 160bb438..017036c9 100644
--- a/api/api/v1/instance/index.ts
+++ b/api/api/v1/instance/index.ts
@@ -1,147 +1,144 @@
-import { apiRoute, auth } from "@/api";
+import { apiRoute } from "@/api";
import { proxyUrl } from "@/response";
-import { createRoute, type z } from "@hono/zod-openapi";
import { InstanceV1 as InstanceV1Schema } from "@versia/client/schemas";
import { Instance, Note, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import type { z } from "zod";
import { markdownParse } from "~/classes/functions/status";
import { config } from "~/config.ts";
import manifest from "~/package.json";
-const route = createRoute({
- method: "get",
- path: "/api/v1/instance",
- summary: "View server information (v1)",
- description:
- "Obtain general information about the server. See api/v2/instance instead.",
- deprecated: true,
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/instance/#v1",
- },
- tags: ["Instance"],
- middleware: [
- auth({
- auth: false,
- }),
- ],
- responses: {
- 200: {
- description: "Instance information",
- content: {
- "application/json": {
- schema: InstanceV1Schema,
- },
- },
- },
- },
-});
-
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- // Get software version from package.json
- const version = manifest.version;
-
- const statusCount = await Note.getCount();
-
- const userCount = await User.getCount();
-
- // Get first admin, or first user if no admin exists
- const contactAccount =
- (await User.fromSql(
- and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
- )) ?? (await User.fromSql(isNull(Users.instanceId)));
-
- const knownDomainsCount = await Instance.getCount();
-
- const oidcConfig = config.plugins?.config?.["@versia/openid"] as
- | {
- forced?: boolean;
- providers?: {
- id: string;
- name: string;
- icon: string;
- }[];
- }
- | undefined;
-
- const content = await markdownParse(
- config.instance.extended_description_path?.content ??
- "This is a [Versia](https://versia.pub) server with the default extended description.",
- );
-
- return context.json({
- approval_required: config.registration.require_approval,
- configuration: {
- polls: {
- max_characters_per_option:
- config.validation.polls.max_option_characters,
- max_expiration:
- config.validation.polls.max_duration_seconds,
- max_options: config.validation.polls.max_options,
- min_expiration:
- config.validation.polls.min_duration_seconds,
- },
- statuses: {
- characters_reserved_per_url: 0,
- max_characters: config.validation.notes.max_characters,
- max_media_attachments:
- config.validation.notes.max_attachments,
- },
- media_attachments: {
- supported_mime_types:
- config.validation.media.allowed_mime_types,
- image_size_limit: config.validation.media.max_bytes,
- // TODO: Implement
- image_matrix_limit: 1 ** 10,
- video_size_limit: 1 ** 10,
- video_frame_rate_limit: 60,
- video_matrix_limit: 1 ** 10,
- },
- accounts: {
- max_featured_tags: 100,
+ app.get(
+ "/api/v1/instance",
+ describeRoute({
+ summary: "View server information (v1)",
+ description:
+ "Obtain general information about the server. See api/v2/instance instead.",
+ deprecated: true,
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/instance/#v1",
+ },
+ tags: ["Instance"],
+ responses: {
+ 200: {
+ description: "Instance information",
+ content: {
+ "application/json": {
+ schema: resolver(InstanceV1Schema),
+ },
+ },
},
},
- short_description: config.instance.description,
- description: content,
- email: config.instance.contact.email,
- invites_enabled: false,
- registrations: config.registration.allow,
- languages: config.instance.languages,
- rules: config.instance.rules.map((r, index) => ({
- id: String(index),
- text: r.text,
- hint: r.hint,
- })),
- stats: {
- domain_count: knownDomainsCount,
- status_count: statusCount,
- user_count: userCount,
- },
- thumbnail: config.instance.branding.logo
- ? proxyUrl(config.instance.branding.logo).toString()
- : null,
- title: config.instance.name,
- uri: config.http.base_url.host,
- urls: {
- // TODO: Implement Streaming API
- streaming_api: "",
- },
- version: "4.3.0-alpha.3+glitch",
- versia_version: version,
- // TODO: Put into plugin directly
- sso: {
- forced: oidcConfig?.forced ?? false,
- providers:
- oidcConfig?.providers?.map((p) => ({
- name: p.name,
- icon: p.icon
- ? proxyUrl(new URL(p.icon)).toString()
- : undefined,
- id: p.id,
- })) ?? [],
- },
- contact_account: (contactAccount as User)?.toApi(),
- } satisfies z.infer);
- }),
+ }),
+ async (context) => {
+ // Get software version from package.json
+ const version = manifest.version;
+
+ const statusCount = await Note.getCount();
+
+ const userCount = await User.getCount();
+
+ // Get first admin, or first user if no admin exists
+ const contactAccount =
+ (await User.fromSql(
+ and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
+ )) ?? (await User.fromSql(isNull(Users.instanceId)));
+
+ const knownDomainsCount = await Instance.getCount();
+
+ const oidcConfig = config.plugins?.config?.["@versia/openid"] as
+ | {
+ forced?: boolean;
+ providers?: {
+ id: string;
+ name: string;
+ icon: string;
+ }[];
+ }
+ | undefined;
+
+ const content = await markdownParse(
+ config.instance.extended_description_path?.content ??
+ "This is a [Versia](https://versia.pub) server with the default extended description.",
+ );
+
+ return context.json({
+ approval_required: config.registration.require_approval,
+ configuration: {
+ polls: {
+ max_characters_per_option:
+ config.validation.polls.max_option_characters,
+ max_expiration:
+ config.validation.polls.max_duration_seconds,
+ max_options: config.validation.polls.max_options,
+ min_expiration:
+ config.validation.polls.min_duration_seconds,
+ },
+ statuses: {
+ characters_reserved_per_url: 0,
+ max_characters: config.validation.notes.max_characters,
+ max_media_attachments:
+ config.validation.notes.max_attachments,
+ },
+ media_attachments: {
+ supported_mime_types:
+ config.validation.media.allowed_mime_types,
+ image_size_limit: config.validation.media.max_bytes,
+ // TODO: Implement
+ image_matrix_limit: 1 ** 10,
+ video_size_limit: 1 ** 10,
+ video_frame_rate_limit: 60,
+ video_matrix_limit: 1 ** 10,
+ },
+ accounts: {
+ max_featured_tags: 100,
+ },
+ },
+ short_description: config.instance.description,
+ description: content,
+ email: config.instance.contact.email,
+ invites_enabled: false,
+ registrations: config.registration.allow,
+ languages: config.instance.languages,
+ rules: config.instance.rules.map((r, index) => ({
+ id: String(index),
+ text: r.text,
+ hint: r.hint,
+ })),
+ stats: {
+ domain_count: knownDomainsCount,
+ status_count: statusCount,
+ user_count: userCount,
+ },
+ thumbnail: config.instance.branding.logo
+ ? proxyUrl(config.instance.branding.logo).toString()
+ : null,
+ title: config.instance.name,
+ uri: config.http.base_url.host,
+ urls: {
+ // TODO: Implement Streaming API
+ streaming_api: "",
+ },
+ version: "4.3.0-alpha.3+glitch",
+ versia_version: version,
+ // TODO: Put into plugin directly
+ sso: {
+ forced: oidcConfig?.forced ?? false,
+ providers:
+ oidcConfig?.providers?.map((p) => ({
+ name: p.name,
+ icon: p.icon
+ ? proxyUrl(new URL(p.icon)).toString()
+ : undefined,
+ id: p.id,
+ })) ?? [],
+ },
+ contact_account: (contactAccount as User)?.toApi(),
+ } satisfies z.infer);
+ },
+ ),
);
diff --git a/api/api/v1/instance/privacy_policy.ts b/api/api/v1/instance/privacy_policy.ts
index 48b6cae8..020a1289 100644
--- a/api/api/v1/instance/privacy_policy.ts
+++ b/api/api/v1/instance/privacy_policy.ts
@@ -1,47 +1,43 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
+import { apiRoute } from "@/api";
import { PrivacyPolicy as PrivacyPolicySchema } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { markdownParse } from "~/classes/functions/status";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "get",
- path: "/api/v1/instance/privacy_policy",
- summary: "View privacy policy",
- description: "Obtain the contents of this server’s privacy policy.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/instance/#privacy_policy",
- },
- tags: ["Instance"],
- middleware: [
- auth({
- auth: false,
- }),
- ],
- responses: {
- 200: {
- description: "Server privacy policy",
- content: {
- "application/json": {
- schema: PrivacyPolicySchema,
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/instance/privacy_policy",
+ describeRoute({
+ summary: "View privacy policy",
+ description: "Obtain the contents of this server’s privacy policy.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/instance/#privacy_policy",
+ },
+ tags: ["Instance"],
+ responses: {
+ 200: {
+ description: "Server privacy policy",
+ content: {
+ "application/json": {
+ schema: resolver(PrivacyPolicySchema),
+ },
+ },
},
},
+ }),
+ async (context) => {
+ const content = await markdownParse(
+ config.instance.privacy_policy_path?.content ??
+ "This instance has not provided any privacy policy.",
+ );
+
+ return context.json({
+ updated_at: new Date(
+ config.instance.privacy_policy_path?.file.lastModified ?? 0,
+ ).toISOString(),
+ content,
+ });
},
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const content = await markdownParse(
- config.instance.privacy_policy_path?.content ??
- "This instance has not provided any privacy policy.",
- );
-
- return context.json({
- updated_at: new Date(
- config.instance.privacy_policy_path?.file.lastModified ?? 0,
- ).toISOString(),
- content,
- });
- }),
+ ),
);
diff --git a/api/api/v1/instance/rules.ts b/api/api/v1/instance/rules.ts
index 879578d1..542775f7 100644
--- a/api/api/v1/instance/rules.ts
+++ b/api/api/v1/instance/rules.ts
@@ -1,42 +1,39 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute } from "@/api";
import { Rule as RuleSchema } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { z } from "zod";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "get",
- path: "/api/v1/instance/rules",
- summary: "List of rules",
- description: "Rules that the users of this service should follow.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/instance/#rules",
- },
- tags: ["Instance"],
- middleware: [
- auth({
- auth: false,
- }),
- ],
- responses: {
- 200: {
- description: "Instance rules",
- content: {
- "application/json": {
- schema: z.array(RuleSchema),
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/instance/rules",
+ describeRoute({
+ summary: "List of rules",
+ description: "Rules that the users of this service should follow.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/instance/#rules",
+ },
+ tags: ["Instance"],
+ responses: {
+ 200: {
+ description: "Instance rules",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(RuleSchema)),
+ },
+ },
},
},
+ }),
+ (context) => {
+ return context.json(
+ config.instance.rules.map((r, index) => ({
+ id: String(index),
+ text: r.text,
+ hint: r.hint,
+ })),
+ );
},
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, (context) => {
- return context.json(
- config.instance.rules.map((r, index) => ({
- id: String(index),
- text: r.text,
- hint: r.hint,
- })),
- );
- }),
+ ),
);
diff --git a/api/api/v1/instance/terms_of_service.ts b/api/api/v1/instance/terms_of_service.ts
index 995769a2..008c7193 100644
--- a/api/api/v1/instance/terms_of_service.ts
+++ b/api/api/v1/instance/terms_of_service.ts
@@ -1,48 +1,44 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
+import { apiRoute } from "@/api";
import { TermsOfService as TermsOfServiceSchema } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { markdownParse } from "~/classes/functions/status";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "get",
- path: "/api/v1/instance/terms_of_service",
- summary: "View terms of service",
- description:
- "Obtain the contents of this server’s terms of service, if configured.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/instance/#terms_of_service",
- },
- tags: ["Instance"],
- middleware: [
- auth({
- auth: false,
- }),
- ],
- responses: {
- 200: {
- description: "Server terms of service",
- content: {
- "application/json": {
- schema: TermsOfServiceSchema,
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/instance/terms_of_service",
+ describeRoute({
+ summary: "View terms of service",
+ description:
+ "Obtain the contents of this server’s terms of service, if configured.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/instance/#terms_of_service",
+ },
+ tags: ["Instance"],
+ responses: {
+ 200: {
+ description: "Server terms of service",
+ content: {
+ "application/json": {
+ schema: resolver(TermsOfServiceSchema),
+ },
+ },
},
},
+ }),
+ async (context) => {
+ const content = await markdownParse(
+ config.instance.tos_path?.content ??
+ "This instance has not provided any terms of service.",
+ );
+
+ return context.json({
+ updated_at: new Date(
+ config.instance.tos_path?.file.lastModified ?? 0,
+ ).toISOString(),
+ content,
+ });
},
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const content = await markdownParse(
- config.instance.tos_path?.content ??
- "This instance has not provided any terms of service.",
- );
-
- return context.json({
- updated_at: new Date(
- config.instance.tos_path?.file.lastModified ?? 0,
- ).toISOString(),
- content,
- });
- }),
+ ),
);
diff --git a/api/api/v1/markers/index.ts b/api/api/v1/markers/index.ts
index 33f880fe..d353ce78 100644
--- a/api/api/v1/markers/index.ts
+++ b/api/api/v1/markers/index.ts
@@ -1,5 +1,4 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import {
Marker as MarkerSchema,
Notification as NotificationSchema,
@@ -9,6 +8,9 @@ import { RolePermission } from "@versia/client/schemas";
import { db } from "@versia/kit/db";
import { Markers } from "@versia/kit/tables";
import { type SQL, and, eq } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
const MarkerResponseSchema = z.object({
@@ -16,221 +18,231 @@ const MarkerResponseSchema = z.object({
home: MarkerSchema.optional(),
});
-const routeGet = createRoute({
- method: "get",
- path: "/api/v1/markers",
- summary: "Get saved timeline positions",
- description: "Get current positions in timelines.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/markers/#get",
- },
- tags: ["Timelines"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnAccount],
- }),
- ] as const,
- request: {
- query: z.object({
- "timeline[]": z
- .array(z.enum(["home", "notifications"]))
- .max(2)
- .or(z.enum(["home", "notifications"]).transform((t) => [t]))
- .optional()
- .openapi({
- description:
- "Specify the timeline(s) for which markers should be fetched. Possible values: home, notifications. If not provided, an empty object will be returned.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "Markers",
- content: {
- "application/json": {
- schema: MarkerResponseSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-const routePost = createRoute({
- method: "post",
- path: "/api/v1/markers",
- summary: "Save your position in a timeline",
- description: "Save current position in timeline.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/markers/#create",
- },
- tags: ["Timelines"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnAccount],
- }),
- ] as const,
- request: {
- query: z
- .object({
- "home[last_read_id]": StatusSchema.shape.id.openapi({
- description:
- "ID of the last status read in the home timeline.",
- example: "c62aa212-8198-4ce5-a388-2cc8344a84ef",
- }),
- "notifications[last_read_id]":
- NotificationSchema.shape.id.openapi({
- description: "ID of the last notification read.",
- }),
- })
- .partial(),
- },
- responses: {
- 200: {
- description: "Markers",
- content: {
- "application/json": {
- schema: MarkerResponseSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
export default apiRoute((app) => {
- app.openapi(routeGet, async (context) => {
- const { "timeline[]": timeline } = context.req.valid("query");
- const { user } = context.get("auth");
+ app.get(
+ "/api/v1/markers",
+ describeRoute({
+ summary: "Get saved timeline positions",
+ description: "Get current positions in timelines.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/markers/#get",
+ },
+ tags: ["Timelines"],
+ responses: {
+ 200: {
+ description: "Markers",
+ content: {
+ "application/json": {
+ schema: resolver(MarkerResponseSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnAccount],
+ }),
+ validator(
+ "query",
+ z.object({
+ "timeline[]": z
+ .array(z.enum(["home", "notifications"]))
+ .max(2)
+ .or(z.enum(["home", "notifications"]).transform((t) => [t]))
+ .optional()
+ .openapi({
+ description:
+ "Specify the timeline(s) for which markers should be fetched. Possible values: home, notifications. If not provided, an empty object will be returned.",
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { "timeline[]": timeline } = context.req.valid("query");
+ const { user } = context.get("auth");
- if (!timeline) {
- return context.json({}, 200);
- }
+ if (!timeline) {
+ return context.json({}, 200);
+ }
- const markers: z.infer = {
- home: undefined,
- notifications: undefined,
- };
+ const markers: z.infer = {
+ home: undefined,
+ notifications: undefined,
+ };
- if (timeline.includes("home")) {
- const found = await db.query.Markers.findFirst({
- where: (marker, { and, eq }): SQL | undefined =>
+ if (timeline.includes("home")) {
+ const found = await db.query.Markers.findFirst({
+ where: (marker, { and, eq }): SQL | undefined =>
+ and(
+ eq(marker.userId, user.id),
+ eq(marker.timeline, "home"),
+ ),
+ });
+
+ const totalCount = await db.$count(
+ Markers,
and(
- eq(marker.userId, user.id),
- eq(marker.timeline, "home"),
+ eq(Markers.userId, user.id),
+ eq(Markers.timeline, "home"),
),
- });
+ );
- const totalCount = await db.$count(
- Markers,
- and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")),
- );
+ if (found?.noteId) {
+ markers.home = {
+ last_read_id: found.noteId,
+ version: totalCount,
+ updated_at: new Date(found.createdAt).toISOString(),
+ };
+ }
+ }
+
+ if (timeline.includes("notifications")) {
+ const found = await db.query.Markers.findFirst({
+ where: (marker, { and, eq }): SQL | undefined =>
+ and(
+ eq(marker.userId, user.id),
+ eq(marker.timeline, "notifications"),
+ ),
+ });
+
+ const totalCount = await db.$count(
+ Markers,
+ and(
+ eq(Markers.userId, user.id),
+ eq(Markers.timeline, "notifications"),
+ ),
+ );
+
+ if (found?.notificationId) {
+ markers.notifications = {
+ last_read_id: found.notificationId,
+ version: totalCount,
+ updated_at: new Date(found.createdAt).toISOString(),
+ };
+ }
+ }
+
+ return context.json(markers, 200);
+ },
+ );
+
+ app.post(
+ "/api/v1/markers",
+ describeRoute({
+ summary: "Save your position in a timeline",
+ description: "Save current position in timeline.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/markers/#create",
+ },
+ tags: ["Timelines"],
+ responses: {
+ 200: {
+ description: "Markers",
+ content: {
+ "application/json": {
+ schema: resolver(MarkerResponseSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnAccount],
+ }),
+ validator(
+ "query",
+ z
+ .object({
+ "home[last_read_id]": StatusSchema.shape.id.openapi({
+ description:
+ "ID of the last status read in the home timeline.",
+ example: "c62aa212-8198-4ce5-a388-2cc8344a84ef",
+ }),
+ "notifications[last_read_id]":
+ NotificationSchema.shape.id.openapi({
+ description: "ID of the last notification read.",
+ }),
+ })
+ .partial(),
+ handleZodError,
+ ),
+ async (context) => {
+ const {
+ "home[last_read_id]": homeId,
+ "notifications[last_read_id]": notificationsId,
+ } = context.req.valid("query");
+ const { user } = context.get("auth");
+
+ const markers: z.infer = {
+ home: undefined,
+ notifications: undefined,
+ };
+
+ if (homeId) {
+ const insertedMarker = (
+ await db
+ .insert(Markers)
+ .values({
+ userId: user.id,
+ timeline: "home",
+ noteId: homeId,
+ })
+ .returning()
+ )[0];
+
+ const totalCount = await db.$count(
+ Markers,
+ and(
+ eq(Markers.userId, user.id),
+ eq(Markers.timeline, "home"),
+ ),
+ );
- if (found?.noteId) {
markers.home = {
- last_read_id: found.noteId,
+ last_read_id: homeId,
version: totalCount,
- updated_at: new Date(found.createdAt).toISOString(),
+ updated_at: new Date(
+ insertedMarker.createdAt,
+ ).toISOString(),
};
}
- }
- if (timeline.includes("notifications")) {
- const found = await db.query.Markers.findFirst({
- where: (marker, { and, eq }): SQL | undefined =>
+ if (notificationsId) {
+ const insertedMarker = (
+ await db
+ .insert(Markers)
+ .values({
+ userId: user.id,
+ timeline: "notifications",
+ notificationId: notificationsId,
+ })
+ .returning()
+ )[0];
+
+ const totalCount = await db.$count(
+ Markers,
and(
- eq(marker.userId, user.id),
- eq(marker.timeline, "notifications"),
+ eq(Markers.userId, user.id),
+ eq(Markers.timeline, "notifications"),
),
- });
+ );
- const totalCount = await db.$count(
- Markers,
- and(
- eq(Markers.userId, user.id),
- eq(Markers.timeline, "notifications"),
- ),
- );
-
- if (found?.notificationId) {
markers.notifications = {
- last_read_id: found.notificationId,
+ last_read_id: notificationsId,
version: totalCount,
- updated_at: new Date(found.createdAt).toISOString(),
+ updated_at: new Date(
+ insertedMarker.createdAt,
+ ).toISOString(),
};
}
- }
- return context.json(markers, 200);
- });
-
- app.openapi(routePost, async (context) => {
- const {
- "home[last_read_id]": homeId,
- "notifications[last_read_id]": notificationsId,
- } = context.req.valid("query");
- const { user } = context.get("auth");
-
- const markers: z.infer = {
- home: undefined,
- notifications: undefined,
- };
-
- if (homeId) {
- const insertedMarker = (
- await db
- .insert(Markers)
- .values({
- userId: user.id,
- timeline: "home",
- noteId: homeId,
- })
- .returning()
- )[0];
-
- const totalCount = await db.$count(
- Markers,
- and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")),
- );
-
- markers.home = {
- last_read_id: homeId,
- version: totalCount,
- updated_at: new Date(insertedMarker.createdAt).toISOString(),
- };
- }
-
- if (notificationsId) {
- const insertedMarker = (
- await db
- .insert(Markers)
- .values({
- userId: user.id,
- timeline: "notifications",
- notificationId: notificationsId,
- })
- .returning()
- )[0];
-
- const totalCount = await db.$count(
- Markers,
- and(
- eq(Markers.userId, user.id),
- eq(Markers.timeline, "notifications"),
- ),
- );
-
- markers.notifications = {
- last_read_id: notificationsId,
- version: totalCount,
- updated_at: new Date(insertedMarker.createdAt).toISOString(),
- };
- }
-
- return context.json(markers, 200);
- });
+ return context.json(markers, 200);
+ },
+ );
});
diff --git a/api/api/v1/media/:id/index.ts b/api/api/v1/media/:id/index.ts
deleted file mode 100644
index a5a0dfa2..00000000
--- a/api/api/v1/media/:id/index.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Attachment as AttachmentSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Media } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const routePut = createRoute({
- method: "put",
- path: "/api/v1/media/{id}",
- summary: "Update media attachment",
- description:
- "Update a MediaAttachment’s parameters, before it is attached to a status and posted.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/media/#update",
- },
- tags: ["Media"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:media"],
- permissions: [RolePermission.ManageOwnMedia],
- }),
- ] as const,
- request: {
- params: z.object({
- id: AttachmentSchema.shape.id,
- }),
- body: {
- content: {
- "multipart/form-data": {
- schema: z
- .object({
- thumbnail: z.instanceof(File).openapi({
- description:
- "The custom thumbnail of the media to be attached, encoded using multipart form data.",
- }),
- description: AttachmentSchema.shape.description,
- focus: z.string().openapi({
- description:
- "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
- },
- }),
- })
- .partial(),
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Updated attachment",
- content: {
- "application/json": {
- schema: AttachmentSchema,
- },
- },
- },
- 404: {
- description: "Attachment not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-const routeGet = createRoute({
- method: "get",
- path: "/api/v1/media/{id}",
- summary: "Get media attachment",
- description:
- "Get a media attachment, before it is attached to a status and posted, but after it is accepted for processing. Use this method to check that the full-sized media has finished processing.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/media/#get",
- },
- tags: ["Media"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnMedia],
- }),
- ] as const,
- request: {
- params: z.object({
- id: AttachmentSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Attachment",
- content: {
- "application/json": {
- schema: AttachmentSchema,
- },
- },
- },
- 404: {
- description: "Attachment not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) => {
- app.openapi(routePut, async (context) => {
- const { id } = context.req.valid("param");
-
- const media = await Media.fromId(id);
-
- if (!media) {
- throw ApiError.mediaNotFound();
- }
-
- const { description, thumbnail: thumbnailFile } =
- context.req.valid("form");
-
- if (thumbnailFile) {
- await media.updateThumbnail(thumbnailFile);
- }
-
- if (description) {
- await media.updateMetadata({
- description,
- });
- }
-
- return context.json(media.toApi(), 200);
- });
-
- app.openapi(routeGet, async (context) => {
- const { id } = context.req.valid("param");
-
- const attachment = await Media.fromId(id);
-
- if (!attachment) {
- throw ApiError.mediaNotFound();
- }
-
- return context.json(attachment.toApi(), 200);
- });
-});
diff --git a/api/api/v1/media/[id]/index.ts b/api/api/v1/media/[id]/index.ts
new file mode 100644
index 00000000..7aab19bb
--- /dev/null
+++ b/api/api/v1/media/[id]/index.ts
@@ -0,0 +1,154 @@
+import { apiRoute, auth, handleZodError } from "@/api";
+import { Attachment as AttachmentSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Media } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) => {
+ app.get(
+ "/api/v1/media/:id",
+ describeRoute({
+ summary: "Get media attachment",
+ description:
+ "Get a media attachment, before it is attached to a status and posted, but after it is accepted for processing. Use this method to check that the full-sized media has finished processing.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/media/#get",
+ },
+ tags: ["Media"],
+ responses: {
+ 200: {
+ description: "Attachment",
+ content: {
+ "application/json": {
+ schema: resolver(AttachmentSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Attachment not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnMedia],
+ }),
+ validator(
+ "param",
+ z.object({
+ id: AttachmentSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+
+ const attachment = await Media.fromId(id);
+
+ if (!attachment) {
+ throw ApiError.mediaNotFound();
+ }
+
+ return context.json(attachment.toApi(), 200);
+ },
+ );
+
+ app.put(
+ "/api/v1/media/:id",
+ describeRoute({
+ summary: "Update media attachment",
+ description:
+ "Update a MediaAttachment’s parameters, before it is attached to a status and posted.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/media/#update",
+ },
+ tags: ["Media"],
+ responses: {
+ 200: {
+ description: "Updated attachment",
+ content: {
+ "application/json": {
+ schema: resolver(AttachmentSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Attachment not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ scopes: ["write:media"],
+ permissions: [RolePermission.ManageOwnMedia],
+ }),
+ validator(
+ "form",
+ z
+ .object({
+ thumbnail: z.instanceof(File).openapi({
+ description:
+ "The custom thumbnail of the media to be attached, encoded using multipart form data.",
+ }),
+ description: AttachmentSchema.shape.description,
+ focus: z.string().openapi({
+ description:
+ "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
+ },
+ }),
+ })
+ .partial(),
+ handleZodError,
+ ),
+ validator(
+ "param",
+ z.object({
+ id: AttachmentSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+
+ const media = await Media.fromId(id);
+
+ if (!media) {
+ throw ApiError.mediaNotFound();
+ }
+
+ const { description, thumbnail: thumbnailFile } =
+ context.req.valid("form");
+
+ if (thumbnailFile) {
+ await media.updateThumbnail(thumbnailFile);
+ }
+
+ if (description) {
+ await media.updateMetadata({
+ description,
+ });
+ }
+
+ return context.json(media.toApi(), 200);
+ },
+ );
+});
diff --git a/api/api/v1/media/index.ts b/api/api/v1/media/index.ts
index f49317cb..aebb3ca1 100644
--- a/api/api/v1/media/index.ts
+++ b/api/api/v1/media/index.ts
@@ -1,98 +1,93 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import { Attachment as AttachmentSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "post",
- path: "/api/v1/media",
- summary: "Upload media as an attachment (v1)",
- description:
- "Creates an attachment to be used with a new status. This method will return after the full sized media is done processing.",
- deprecated: true,
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/media/#v1",
- },
- tags: ["Media"],
- middleware: [
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/media",
+ describeRoute({
+ summary: "Upload media as an attachment (v1)",
+ description:
+ "Creates an attachment to be used with a new status. This method will return after the full sized media is done processing.",
+ deprecated: true,
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/media/#v1",
+ },
+ tags: ["Media"],
+ responses: {
+ 200: {
+ description:
+ "Attachment created successfully. Note that the MediaAttachment will be created even if the file is not understood correctly due to failed processing.",
+ content: {
+ "application/json": {
+ schema: resolver(AttachmentSchema),
+ },
+ },
+ },
+ 413: {
+ description: "File too large",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 415: {
+ description: "Disallowed file type",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
scopes: ["write:media"],
permissions: [RolePermission.ManageOwnMedia],
}),
- ] as const,
- request: {
- body: {
- content: {
- "multipart/form-data": {
- schema: z.object({
- file: z.instanceof(File).openapi({
- description:
- "The file to be attached, encoded using multipart form data. The file must have a MIME type.",
- }),
- thumbnail: z.instanceof(File).optional().openapi({
- description:
- "The custom thumbnail of the media to be attached, encoded using multipart form data.",
- }),
+ validator(
+ "form",
+ z.object({
+ file: z.instanceof(File).openapi({
+ description:
+ "The file to be attached, encoded using multipart form data. The file must have a MIME type.",
+ }),
+ thumbnail: z.instanceof(File).optional().openapi({
+ description:
+ "The custom thumbnail of the media to be attached, encoded using multipart form data.",
+ }),
+ description: AttachmentSchema.shape.description.optional(),
+ focus: z
+ .string()
+ .optional()
+ .openapi({
description:
- AttachmentSchema.shape.description.optional(),
- focus: z
- .string()
- .optional()
- .openapi({
- description:
- "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
- },
- }),
+ "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
+ },
}),
- },
- },
- },
- },
- responses: {
- 200: {
- description:
- "Attachment created successfully. Note that the MediaAttachment will be created even if the file is not understood correctly due to failed processing.",
- content: {
- "application/json": {
- schema: AttachmentSchema,
- },
- },
- },
- 413: {
- description: "File too large",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 415: {
- description: "Disallowed file type",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { file, thumbnail, description } = context.req.valid("form");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { file, thumbnail, description } = context.req.valid("form");
+ const attachment = await Media.fromFile(file, {
+ thumbnail,
+ description: description ?? undefined,
+ });
- const attachment = await Media.fromFile(file, {
- thumbnail,
- description: description ?? undefined,
- });
-
- return context.json(attachment.toApi(), 200);
- }),
+ return context.json(attachment.toApi(), 200);
+ },
+ ),
);
diff --git a/api/api/v1/mutes/index.ts b/api/api/v1/mutes/index.ts
index 859f07c4..3117beab 100644
--- a/api/api/v1/mutes/index.ts
+++ b/api/api/v1/mutes/index.ts
@@ -1,99 +1,109 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/mutes",
- summary: "View muted accounts",
- description: "View your mutes.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/mutes/#get",
- },
- tags: ["Mutes"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/mutes",
+ describeRoute({
+ summary: "View muted accounts",
+ description: "View your mutes.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/mutes/#get",
+ },
+ tags: ["Mutes"],
+ responses: {
+ 200: {
+ description: "List of muted users",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(AccountSchema)),
+ },
+ },
+ headers: z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
scopes: ["read:mutes"],
permissions: [RolePermission.ManageOwnMutes],
}),
- ] as const,
- request: {
- query: z.object({
- max_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: AccountSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
- description: "Maximum number of results to return.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "List of muted users",
- content: {
- "application/json": {
- schema: z.array(AccountSchema),
- },
- },
- headers: z.object({
- link: z
- .string()
- .optional()
+ validator(
+ "query",
+ z.object({
+ max_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(80)
+ .default(40)
.openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
+ description: "Maximum number of results to return.",
}),
}),
+ handleZodError,
+ ),
+ async (context) => {
+ const { max_id, since_id, limit, min_id } =
+ context.req.valid("query");
+ const { user } = context.get("auth");
+
+ const { objects: mutes, link } = await Timeline.getUserTimeline(
+ and(
+ max_id ? lt(Users.id, max_id) : undefined,
+ since_id ? gte(Users.id, since_id) : undefined,
+ min_id ? gt(Users.id, min_id) : undefined,
+ sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
+ ),
+ limit,
+ new URL(context.req.url),
+ );
+
+ return context.json(
+ mutes.map((u) => u.toApi()),
+ 200,
+ {
+ Link: link,
+ },
+ );
},
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, since_id, limit, min_id } = context.req.valid("query");
- const { user } = context.get("auth");
-
- const { objects: mutes, link } = await Timeline.getUserTimeline(
- and(
- max_id ? lt(Users.id, max_id) : undefined,
- since_id ? gte(Users.id, since_id) : undefined,
- min_id ? gt(Users.id, min_id) : undefined,
- sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
- ),
- limit,
- new URL(context.req.url),
- );
-
- return context.json(
- mutes.map((u) => u.toApi()),
- 200,
- {
- Link: link,
- },
- );
- }),
+ ),
);
diff --git a/api/api/v1/notifications/:id/dismiss.ts b/api/api/v1/notifications/:id/dismiss.ts
deleted file mode 100644
index 092df632..00000000
--- a/api/api/v1/notifications/:id/dismiss.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Notification as NotificationSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Notification } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/notifications/{id}/dismiss",
- summary: "Dismiss a single notification",
- description: "Dismiss a single notification from the server.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/notifications/#dismiss",
- },
- tags: ["Notifications"],
- middleware: [
- auth({
- auth: true,
- scopes: ["write:notifications"],
- permissions: [RolePermission.ManageOwnNotifications],
- }),
- ] as const,
- request: {
- params: z.object({
- id: NotificationSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Notification with given ID successfully dismissed",
- },
- 401: ApiError.missingAuthentication().schema,
- 404: ApiError.notificationNotFound().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { id } = context.req.valid("param");
-
- const { user } = context.get("auth");
-
- const notification = await Notification.fromId(id);
-
- if (!notification || notification.data.notifiedId !== user.id) {
- throw ApiError.notificationNotFound();
- }
-
- await notification.update({
- dismissed: true,
- });
-
- return context.text("", 200);
- }),
-);
diff --git a/api/api/v1/notifications/:id/index.ts b/api/api/v1/notifications/:id/index.ts
deleted file mode 100644
index da8f1e21..00000000
--- a/api/api/v1/notifications/:id/index.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Notification as NotificationSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Notification } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/notifications/{id}",
- summary: "Get a single notification",
- description: "View information about a notification with a given ID.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/notifications/#get",
- },
- tags: ["Notifications"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnNotifications],
- scopes: ["read:notifications"],
- }),
- ] as const,
- request: {
- params: z.object({
- id: NotificationSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "A single Notification",
- content: {
- "application/json": {
- schema: NotificationSchema,
- },
- },
- },
- 404: ApiError.notificationNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { id } = context.req.valid("param");
-
- const { user } = context.get("auth");
-
- const notification = await Notification.fromId(id, user.id);
-
- if (!notification || notification.data.notifiedId !== user.id) {
- throw ApiError.notificationNotFound();
- }
-
- return context.json(await notification.toApi(), 200);
- }),
-);
diff --git a/api/api/v1/notifications/:id/dismiss.test.ts b/api/api/v1/notifications/[id]/dismiss.test.ts
similarity index 97%
rename from api/api/v1/notifications/:id/dismiss.test.ts
rename to api/api/v1/notifications/[id]/dismiss.test.ts
index 80a399df..a9035dff 100644
--- a/api/api/v1/notifications/:id/dismiss.test.ts
+++ b/api/api/v1/notifications/[id]/dismiss.test.ts
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
-import type { z } from "@hono/zod-openapi";
import type { Notification } from "@versia/client/schemas";
+import type { z } from "zod";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
diff --git a/api/api/v1/notifications/[id]/dismiss.ts b/api/api/v1/notifications/[id]/dismiss.ts
new file mode 100644
index 00000000..3a872364
--- /dev/null
+++ b/api/api/v1/notifications/[id]/dismiss.ts
@@ -0,0 +1,59 @@
+import { apiRoute, auth, handleZodError } from "@/api";
+import { Notification as NotificationSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Notification } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/notifications/:id/dismiss",
+ describeRoute({
+ summary: "Dismiss a single notification",
+ description: "Dismiss a single notification from the server.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/notifications/#dismiss",
+ },
+ tags: ["Notifications"],
+ responses: {
+ 200: {
+ description:
+ "Notification with given ID successfully dismissed",
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 404: ApiError.notificationNotFound().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ scopes: ["write:notifications"],
+ permissions: [RolePermission.ManageOwnNotifications],
+ }),
+ validator(
+ "param",
+ z.object({
+ id: NotificationSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+
+ const { user } = context.get("auth");
+
+ const notification = await Notification.fromId(id);
+
+ if (!notification || notification.data.notifiedId !== user.id) {
+ throw ApiError.notificationNotFound();
+ }
+
+ await notification.update({
+ dismissed: true,
+ });
+
+ return context.text("", 200);
+ },
+ ),
+);
diff --git a/api/api/v1/notifications/:id/index.test.ts b/api/api/v1/notifications/[id]/index.test.ts
similarity index 98%
rename from api/api/v1/notifications/:id/index.test.ts
rename to api/api/v1/notifications/[id]/index.test.ts
index 6d763f4e..7bda7499 100644
--- a/api/api/v1/notifications/:id/index.test.ts
+++ b/api/api/v1/notifications/[id]/index.test.ts
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
-import type { z } from "@hono/zod-openapi";
import type { Notification } from "@versia/client/schemas";
+import type { z } from "zod";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
diff --git a/api/api/v1/notifications/[id]/index.ts b/api/api/v1/notifications/[id]/index.ts
new file mode 100644
index 00000000..8bcb2288
--- /dev/null
+++ b/api/api/v1/notifications/[id]/index.ts
@@ -0,0 +1,60 @@
+import { apiRoute, auth, handleZodError } from "@/api";
+import { Notification as NotificationSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Notification } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/notifications/:id",
+ describeRoute({
+ summary: "Get a single notification",
+ description:
+ "View information about a notification with a given ID.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/notifications/#get",
+ },
+ tags: ["Notifications"],
+ responses: {
+ 200: {
+ description: "A single Notification",
+ content: {
+ "application/json": {
+ schema: resolver(NotificationSchema),
+ },
+ },
+ },
+ 404: ApiError.notificationNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnNotifications],
+ scopes: ["read:notifications"],
+ }),
+ validator(
+ "param",
+ z.object({
+ id: NotificationSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+
+ const { user } = context.get("auth");
+
+ const notification = await Notification.fromId(id, user.id);
+
+ if (!notification || notification.data.notifiedId !== user.id) {
+ throw ApiError.notificationNotFound();
+ }
+
+ return context.json(await notification.toApi(), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/notifications/clear/index.ts b/api/api/v1/notifications/clear/index.ts
index b9a17c75..931c0be8 100644
--- a/api/api/v1/notifications/clear/index.ts
+++ b/api/api/v1/notifications/clear/index.ts
@@ -1,38 +1,36 @@
import { apiRoute, auth } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "post",
- path: "/api/v1/notifications/clear",
- summary: "Dismiss all notifications",
- description: "Clear all notifications from the server.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/notifications/#clear",
- },
- tags: ["Notifications"],
- middleware: [
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/notifications/clear",
+ describeRoute({
+ summary: "Dismiss all notifications",
+ description: "Clear all notifications from the server.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/notifications/#clear",
+ },
+ tags: ["Notifications"],
+ responses: {
+ 200: {
+ description: "Notifications successfully cleared.",
+ },
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
auth({
auth: true,
permissions: [RolePermission.ManageOwnNotifications],
scopes: ["write:notifications"],
}),
- ] as const,
- responses: {
- 200: {
- description: "Notifications successfully cleared.",
+ async (context) => {
+ const { user } = context.get("auth");
+
+ await user.clearAllNotifications();
+
+ return context.text("", 200);
},
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
-
- await user.clearAllNotifications();
-
- return context.text("", 200);
- }),
+ ),
);
diff --git a/api/api/v1/notifications/destroy_multiple/index.test.ts b/api/api/v1/notifications/destroy_multiple/index.test.ts
index baa3e098..cb292b6b 100644
--- a/api/api/v1/notifications/destroy_multiple/index.test.ts
+++ b/api/api/v1/notifications/destroy_multiple/index.test.ts
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
-import type { z } from "@hono/zod-openapi";
import type { Notification } from "@versia/client/schemas";
+import type { z } from "zod";
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
diff --git a/api/api/v1/notifications/destroy_multiple/index.ts b/api/api/v1/notifications/destroy_multiple/index.ts
index b866e9b8..0dbf9922 100644
--- a/api/api/v1/notifications/destroy_multiple/index.ts
+++ b/api/api/v1/notifications/destroy_multiple/index.ts
@@ -1,45 +1,44 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const schemas = {
- query: z.object({
- "ids[]": z.array(z.string().uuid()),
- }),
-};
-
-const route = createRoute({
- method: "delete",
- path: "/api/v1/notifications/destroy_multiple",
- summary: "Dismiss multiple notifications",
- tags: ["Notifications"],
- middleware: [
+export default apiRoute((app) =>
+ app.delete(
+ "/api/v1/notifications/destroy_multiple",
+ describeRoute({
+ summary: "Dismiss multiple notifications",
+ tags: ["Notifications"],
+ responses: {
+ 200: {
+ description: "Notifications dismissed",
+ },
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
auth({
auth: true,
permissions: [RolePermission.ManageOwnNotifications],
scopes: ["write:notifications"],
}),
- ] as const,
- request: {
- query: schemas.query,
- },
- responses: {
- 200: {
- description: "Notifications dismissed",
+ qsQuery(),
+ validator(
+ "query",
+ z.object({
+ ids: z.array(z.string().uuid()),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+
+ const { ids } = context.req.valid("query");
+
+ await user.clearSomeNotifications(ids);
+
+ return context.text("", 200);
},
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
-
- const { "ids[]": ids } = context.req.valid("query");
-
- await user.clearSomeNotifications(ids);
-
- return context.text("", 200);
- }),
+ ),
);
diff --git a/api/api/v1/notifications/index.ts b/api/api/v1/notifications/index.ts
index d762dd05..9c999c81 100644
--- a/api/api/v1/notifications/index.ts
+++ b/api/api/v1/notifications/index.ts
@@ -1,5 +1,4 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import {
Account as AccountSchema,
Notification as NotificationSchema,
@@ -9,18 +8,34 @@ import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Notifications } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/notifications",
- summary: "Get all notifications",
- description: "Notifications concerning the user.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/notifications/#get",
- },
- tags: ["Notifications"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/notifications",
+ describeRoute({
+ summary: "Get all notifications",
+ description: "Notifications concerning the user.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/notifications/#get",
+ },
+ tags: ["Notifications"],
+ responses: {
+ 200: {
+ description: "Notifications",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(NotificationSchema)),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
permissions: [
@@ -28,135 +43,122 @@ const route = createRoute({
RolePermission.ViewPrivateTimelines,
],
}),
- ] as const,
- request: {
- query: z
- .object({
- max_id: NotificationSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: NotificationSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: NotificationSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce
- .number()
- .int()
- .min(1)
- .max(80)
- .default(40)
- .openapi({
- description: "Maximum number of results to return.",
+ validator(
+ "query",
+ z
+ .object({
+ max_id: NotificationSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
- types: z
- .array(NotificationSchema.shape.type)
- .optional()
- .openapi({
- description: "Types to include in the result.",
+ since_id: NotificationSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
}),
- exclude_types: z
- .array(NotificationSchema.shape.type)
- .optional()
- .openapi({
- description: "Types to exclude from the results.",
+ min_id: NotificationSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
}),
- account_id: AccountSchema.shape.id.optional().openapi({
- description:
- "Return only notifications received from the specified account.",
- }),
- // TODO: Implement
- include_filtered: zBoolean.default(false).openapi({
- description:
- "Whether to include notifications filtered by the user’s NotificationPolicy.",
- }),
- })
- .refine((val) => {
- // Can't use both exclude_types and types
- return !(val.exclude_types && val.types);
- }, "Can't use both exclude_types and types"),
- },
- responses: {
- 200: {
- description: "Notifications",
- content: {
- "application/json": {
- schema: z.array(NotificationSchema),
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(80)
+ .default(40)
+ .openapi({
+ description: "Maximum number of results to return.",
+ }),
+ types: z
+ .array(NotificationSchema.shape.type)
+ .optional()
+ .openapi({
+ description: "Types to include in the result.",
+ }),
+ exclude_types: z
+ .array(NotificationSchema.shape.type)
+ .optional()
+ .openapi({
+ description: "Types to exclude from the results.",
+ }),
+ account_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "Return only notifications received from the specified account.",
+ }),
+ // TODO: Implement
+ include_filtered: zBoolean.default(false).openapi({
+ description:
+ "Whether to include notifications filtered by the user's NotificationPolicy.",
+ }),
+ })
+ .refine((val) => {
+ // Can't use both exclude_types and types
+ return !(val.exclude_types && val.types);
+ }, "Can't use both exclude_types and types"),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
+ const {
+ account_id,
+ exclude_types,
+ limit,
+ max_id,
+ min_id,
+ since_id,
+ types,
+ } = context.req.valid("query");
- const {
- account_id,
- exclude_types,
- limit,
- max_id,
- min_id,
- since_id,
- types,
- } = context.req.valid("query");
-
- const { objects, link } = await Timeline.getNotificationTimeline(
- and(
- max_id ? lt(Notifications.id, max_id) : undefined,
- since_id ? gte(Notifications.id, since_id) : undefined,
- min_id ? gt(Notifications.id, min_id) : undefined,
- eq(Notifications.notifiedId, user.id),
- eq(Notifications.dismissed, false),
- account_id
- ? eq(Notifications.accountId, account_id)
- : undefined,
- not(eq(Notifications.accountId, user.id)),
- types ? inArray(Notifications.type, types) : undefined,
- exclude_types
- ? not(inArray(Notifications.type, exclude_types))
- : undefined,
- // Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
- // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
- // Filters table has a userId and a context which is an array
- sql`NOT EXISTS (
- SELECT 1
- FROM "Filters"
- WHERE "Filters"."userId" = ${user.id}
- AND "Filters"."filter_action" = 'hide'
- AND EXISTS (
+ const { objects, link } = await Timeline.getNotificationTimeline(
+ and(
+ max_id ? lt(Notifications.id, max_id) : undefined,
+ since_id ? gte(Notifications.id, since_id) : undefined,
+ min_id ? gt(Notifications.id, min_id) : undefined,
+ eq(Notifications.notifiedId, user.id),
+ eq(Notifications.dismissed, false),
+ account_id
+ ? eq(Notifications.accountId, account_id)
+ : undefined,
+ not(eq(Notifications.accountId, user.id)),
+ types ? inArray(Notifications.type, types) : undefined,
+ exclude_types
+ ? not(inArray(Notifications.type, exclude_types))
+ : undefined,
+ // Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
+ // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
+ // Filters table has a userId and a context which is an array
+ sql`NOT EXISTS (
SELECT 1
- FROM "FilterKeywords", "Notifications" as "n_inner", "Notes"
- WHERE "FilterKeywords"."filterId" = "Filters"."id"
- AND "n_inner"."noteId" = "Notes"."id"
- AND "Notes"."content" LIKE
- '%' || "FilterKeywords"."keyword" || '%'
- AND "n_inner"."id" = "Notifications"."id"
- )
- AND "Filters"."context" @> ARRAY['notifications']
- )`,
- ),
- limit,
- new URL(context.req.url),
- user.id,
- );
+ FROM "Filters"
+ WHERE "Filters"."userId" = ${user.id}
+ AND "Filters"."filter_action" = 'hide'
+ AND EXISTS (
+ SELECT 1
+ FROM "FilterKeywords", "Notifications" as "n_inner", "Notes"
+ WHERE "FilterKeywords"."filterId" = "Filters"."id"
+ AND "n_inner"."noteId" = "Notes"."id"
+ AND "Notes"."content" LIKE
+ '%' || "FilterKeywords"."keyword" || '%'
+ AND "n_inner"."id" = "Notifications"."id"
+ )
+ AND "Filters"."context" @> ARRAY['notifications']
+ )`,
+ ),
+ limit,
+ new URL(context.req.url),
+ user.id,
+ );
- return context.json(
- await Promise.all(objects.map((n) => n.toApi())),
- 200,
- {
- Link: link,
- },
- );
- }),
+ return context.json(
+ await Promise.all(objects.map((n) => n.toApi())),
+ 200,
+ {
+ Link: link,
+ },
+ );
+ },
+ ),
);
diff --git a/api/api/v1/profile/avatar.ts b/api/api/v1/profile/avatar.ts
index ba3bbfa7..1ce6bcf9 100644
--- a/api/api/v1/profile/avatar.ts
+++ b/api/api/v1/profile/avatar.ts
@@ -1,48 +1,48 @@
import { apiRoute, auth } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
import { Account } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "delete",
- path: "/api/v1/profile/avatar",
- summary: "Delete profile avatar",
- description: "Deletes the avatar associated with the user’s profile.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar",
- },
- tags: ["Profile"],
- middleware: [
+export default apiRoute((app) =>
+ app.delete(
+ "/api/v1/profile/avatar",
+ describeRoute({
+ summary: "Delete profile avatar",
+ description:
+ "Deletes the avatar associated with the user’s profile.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar",
+ },
+ tags: ["Profile"],
+ responses: {
+ 200: {
+ description:
+ "The avatar was successfully deleted from the user’s profile. If there were no avatar associated with the profile, the response will still indicate a successful deletion.",
+ content: {
+ "application/json": {
+ // TODO: Return a CredentialAccount
+ schema: resolver(Account),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
permissions: [RolePermission.ManageOwnAccount],
scopes: ["write:account"],
}),
- ] as const,
- responses: {
- 200: {
- description:
- "The avatar was successfully deleted from the user’s profile. If there were no avatar associated with the profile, the response will still indicate a successful deletion.",
- content: {
- "application/json": {
- // TODO: Return a CredentialAccount
- schema: Account,
- },
- },
+ async (context) => {
+ const { user } = context.get("auth");
+
+ await user.avatar?.delete();
+ await user.reload();
+
+ return context.json(user.toApi(true), 200);
},
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
-
- await user.avatar?.delete();
- await user.reload();
-
- return context.json(user.toApi(true), 200);
- }),
+ ),
);
diff --git a/api/api/v1/profile/header.ts b/api/api/v1/profile/header.ts
index 724c278f..98f728ec 100644
--- a/api/api/v1/profile/header.ts
+++ b/api/api/v1/profile/header.ts
@@ -1,46 +1,46 @@
import { apiRoute, auth } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
import { Account } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "delete",
- path: "/api/v1/profile/header",
- summary: "Delete profile header",
- description: "Deletes the header image associated with the user’s profile.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-header",
- },
- tags: ["Profiles"],
- middleware: [
+export default apiRoute((app) =>
+ app.delete(
+ "/api/v1/profile/header",
+ describeRoute({
+ summary: "Delete profile header",
+ description:
+ "Deletes the header image associated with the user’s profile.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-header",
+ },
+ tags: ["Profiles"],
+ responses: {
+ 200: {
+ description:
+ "The header was successfully deleted from the user’s profile. If there were no header associated with the profile, the response will still indicate a successful deletion.",
+ content: {
+ "application/json": {
+ schema: resolver(Account),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
permissions: [RolePermission.ManageOwnAccount],
scopes: ["write:account"],
}),
- ] as const,
- responses: {
- 200: {
- description:
- "The header was successfully deleted from the user’s profile. If there were no header associated with the profile, the response will still indicate a successful deletion.",
- content: {
- "application/json": {
- schema: Account,
- },
- },
+ async (context) => {
+ const { user } = context.get("auth");
+
+ await user.header?.delete();
+ await user.reload();
+ return context.json(user.toApi(true), 200);
},
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
-
- await user.header?.delete();
- await user.reload();
- return context.json(user.toApi(true), 200);
- }),
+ ),
);
diff --git a/api/api/v1/push/subscription/index.delete.ts b/api/api/v1/push/subscription/index.delete.ts
index 05b6f689..ecab9452 100644
--- a/api/api/v1/push/subscription/index.delete.ts
+++ b/api/api/v1/push/subscription/index.delete.ts
@@ -1,34 +1,28 @@
import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas";
import { PushSubscription } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
- app.openapi(
- createRoute({
- method: "delete",
- path: "/api/v1/push/subscription",
+ app.delete(
+ "/api/v1/push/subscription",
+ describeRoute({
summary: "Remove current subscription",
description: "Removes the current Web Push API subscription.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/push/#delete",
},
tags: ["Push Notifications"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.UsePushNotifications],
- scopes: ["push"],
- }),
- ] as const,
responses: {
200: {
description:
"PushSubscription successfully deleted or did not exist previously.",
content: {
"application/json": {
- schema: z.object({}),
+ schema: resolver(z.object({})),
},
},
},
@@ -36,6 +30,11 @@ export default apiRoute((app) =>
422: ApiError.validationFailed().schema,
},
}),
+ auth({
+ auth: true,
+ permissions: [RolePermission.UsePushNotifications],
+ scopes: ["push"],
+ }),
async (context) => {
const { token } = context.get("auth");
diff --git a/api/api/v1/push/subscription/index.get.ts b/api/api/v1/push/subscription/index.get.ts
index 3f18acdb..1c945519 100644
--- a/api/api/v1/push/subscription/index.get.ts
+++ b/api/api/v1/push/subscription/index.get.ts
@@ -1,15 +1,15 @@
import { apiRoute, auth } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
import { WebPushSubscription as WebPushSubscriptionSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { PushSubscription } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
- app.openapi(
- createRoute({
- method: "get",
- path: "/api/v1/push/subscription",
+ app.get(
+ "/api/v1/push/subscription",
+ describeRoute({
summary: "Get current subscription",
description:
"View the PushSubscription currently associated with this access token.",
@@ -17,19 +17,12 @@ export default apiRoute((app) =>
url: "https://docs.joinmastodon.org/methods/push/#get",
},
tags: ["Push Notifications"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.UsePushNotifications],
- scopes: ["push"],
- }),
- ] as const,
responses: {
200: {
description: "WebPushSubscription",
content: {
"application/json": {
- schema: WebPushSubscriptionSchema,
+ schema: resolver(WebPushSubscriptionSchema),
},
},
},
@@ -37,6 +30,11 @@ export default apiRoute((app) =>
422: ApiError.validationFailed().schema,
},
}),
+ auth({
+ auth: true,
+ permissions: [RolePermission.UsePushNotifications],
+ scopes: ["push"],
+ }),
async (context) => {
const { token } = context.get("auth");
diff --git a/api/api/v1/push/subscription/index.post.ts b/api/api/v1/push/subscription/index.post.ts
index dbe3a310..b4255228 100644
--- a/api/api/v1/push/subscription/index.post.ts
+++ b/api/api/v1/push/subscription/index.post.ts
@@ -1,19 +1,19 @@
-import { apiRoute } from "@/api";
+import { apiRoute, handleZodError } from "@/api";
import { auth, jsonOrForm } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
import {
WebPushSubscriptionInput,
WebPushSubscription as WebPushSubscriptionSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { PushSubscription } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
- app.openapi(
- createRoute({
- method: "post",
- path: "/api/v1/push/subscription",
+ app.post(
+ "/api/v1/push/subscription",
+ describeRoute({
summary: "Subscribe to push notifications",
description:
"Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.",
@@ -21,30 +21,13 @@ export default apiRoute((app) =>
url: "https://docs.joinmastodon.org/methods/push/#create",
},
tags: ["Push Notifications"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.UsePushNotifications],
- scopes: ["push"],
- }),
- jsonOrForm(),
- ] as const,
- request: {
- body: {
- content: {
- "application/json": {
- schema: WebPushSubscriptionInput,
- },
- },
- },
- },
responses: {
200: {
description:
"A new PushSubscription has been generated, which will send the requested alerts to your endpoint.",
content: {
"application/json": {
- schema: WebPushSubscriptionSchema,
+ schema: resolver(WebPushSubscriptionSchema),
},
},
},
@@ -52,6 +35,13 @@ export default apiRoute((app) =>
422: ApiError.validationFailed().schema,
},
}),
+ auth({
+ auth: true,
+ permissions: [RolePermission.UsePushNotifications],
+ scopes: ["push"],
+ }),
+ jsonOrForm(),
+ validator("json", WebPushSubscriptionInput, handleZodError),
async (context) => {
const { user, token } = context.get("auth");
const { subscription, data, policy } = context.req.valid("json");
diff --git a/api/api/v1/push/subscription/index.put.ts b/api/api/v1/push/subscription/index.put.ts
index 77be61b8..31f4dac5 100644
--- a/api/api/v1/push/subscription/index.put.ts
+++ b/api/api/v1/push/subscription/index.put.ts
@@ -1,18 +1,18 @@
-import { apiRoute, auth, jsonOrForm } from "@/api";
-import { createRoute } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import {
WebPushSubscriptionInput,
WebPushSubscription as WebPushSubscriptionSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { PushSubscription } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
- app.openapi(
- createRoute({
- method: "put",
- path: "/api/v1/push/subscription",
+ app.put(
+ "/api/v1/push/subscription",
+ describeRoute({
summary: "Change types of notifications",
description:
"Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.",
@@ -20,32 +20,12 @@ export default apiRoute((app) =>
url: "https://docs.joinmastodon.org/methods/push/#update",
},
tags: ["Push Notifications"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.UsePushNotifications],
- scopes: ["push"],
- }),
- jsonOrForm(),
- ] as const,
- request: {
- body: {
- content: {
- "application/json": {
- schema: WebPushSubscriptionInput.pick({
- data: true,
- policy: true,
- }),
- },
- },
- },
- },
responses: {
200: {
description: "The WebPushSubscription has been updated.",
content: {
"application/json": {
- schema: WebPushSubscriptionSchema,
+ schema: resolver(WebPushSubscriptionSchema),
},
},
},
@@ -53,6 +33,20 @@ export default apiRoute((app) =>
422: ApiError.validationFailed().schema,
},
}),
+ auth({
+ auth: true,
+ permissions: [RolePermission.UsePushNotifications],
+ scopes: ["push"],
+ }),
+ jsonOrForm(),
+ validator(
+ "json",
+ WebPushSubscriptionInput.pick({
+ data: true,
+ policy: true,
+ }),
+ handleZodError,
+ ),
async (context) => {
const { user, token } = context.get("auth");
const { data, policy } = context.req.valid("json");
diff --git a/api/api/v1/roles/:id/index.ts b/api/api/v1/roles/:id/index.ts
deleted file mode 100644
index 99cfd70a..00000000
--- a/api/api/v1/roles/:id/index.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Role as RoleSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Role } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const routeGet = createRoute({
- method: "get",
- path: "/api/v1/roles/{id}",
- summary: "Get role data",
- tags: ["Roles"],
- middleware: [
- auth({
- auth: true,
- }),
- ],
- request: {
- params: z.object({
- id: z.string().uuid(),
- }),
- },
- responses: {
- 200: {
- description: "Role",
- content: {
- "application/json": {
- schema: RoleSchema,
- },
- },
- },
- 404: ApiError.roleNotFound().schema,
- 403: ApiError.forbidden().schema,
- },
-});
-
-const routePatch = createRoute({
- method: "patch",
- path: "/api/v1/roles/{id}",
- summary: "Update role data",
- tags: ["Roles"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageRoles],
- }),
- ] as const,
- request: {
- params: z.object({
- id: z.string().uuid(),
- }),
- body: {
- content: {
- "application/json": {
- schema: RoleSchema.omit({ id: true }).partial(),
- },
- },
- },
- },
- responses: {
- 204: {
- description: "Role updated",
- },
- 404: ApiError.roleNotFound().schema,
- 403: ApiError.forbidden().schema,
- },
-});
-
-const routeDelete = createRoute({
- method: "delete",
- path: "/api/v1/roles/{id}",
- summary: "Delete role",
- tags: ["Roles"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageRoles],
- }),
- ] as const,
- request: {
- params: z.object({
- id: z.string().uuid(),
- }),
- },
- responses: {
- 204: {
- description: "Role deleted",
- },
- 404: ApiError.roleNotFound().schema,
- 403: ApiError.forbidden().schema,
- },
-});
-
-export default apiRoute((app) => {
- app.openapi(routeGet, async (context) => {
- const { id } = context.req.valid("param");
-
- const role = await Role.fromId(id);
-
- if (!role) {
- throw ApiError.roleNotFound();
- }
-
- return context.json(role.toApi(), 200);
- });
-
- app.openapi(routePatch, async (context) => {
- const { user } = context.get("auth");
- const { id } = context.req.valid("param");
- const { permissions, priority, description, icon, name, visible } =
- context.req.valid("json");
-
- const role = await Role.fromId(id);
-
- if (!role) {
- throw ApiError.roleNotFound();
- }
-
- // Priority check
- const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
-
- const userHighestRole = userRoles.reduce((prev, current) =>
- prev.data.priority > current.data.priority ? prev : current,
- );
-
- if (role.data.priority > userHighestRole.data.priority) {
- throw new ApiError(
- 403,
- "Forbidden",
- `User with highest role priority ${userHighestRole.data.priority} cannot edit role with priority ${role.data.priority}`,
- );
- }
-
- // If updating role permissions, the user must already have the permissions they wish to add/remove
- if (permissions) {
- const userPermissions = user.getAllPermissions();
- const hasPermissions = (
- permissions as unknown as RolePermission[]
- ).every((p) => userPermissions.includes(p));
-
- if (!hasPermissions) {
- throw new ApiError(
- 403,
- "Forbidden",
- "User cannot add or remove permissions they do not have",
- );
- }
- }
-
- await role.update({
- permissions: permissions as unknown as RolePermission[],
- priority,
- description,
- icon,
- name,
- visible,
- });
-
- return context.body(null, 204);
- });
-
- app.openapi(routeDelete, async (context) => {
- const { user } = context.get("auth");
- const { id } = context.req.valid("param");
-
- const role = await Role.fromId(id);
-
- if (!role) {
- throw ApiError.roleNotFound();
- }
-
- // Priority check
- const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
-
- const userHighestRole = userRoles.reduce((prev, current) =>
- prev.data.priority > current.data.priority ? prev : current,
- );
-
- if (role.data.priority > userHighestRole.data.priority) {
- throw new ApiError(
- 403,
- "Forbidden",
- `User with highest role priority ${userHighestRole.data.priority} cannot delete role with priority ${role.data.priority}`,
- );
- }
-
- await role.delete();
-
- return context.body(null, 204);
- });
-});
diff --git a/api/api/v1/roles/:id/index.test.ts b/api/api/v1/roles/[id]/index.test.ts
similarity index 100%
rename from api/api/v1/roles/:id/index.test.ts
rename to api/api/v1/roles/[id]/index.test.ts
diff --git a/api/api/v1/roles/[id]/index.ts b/api/api/v1/roles/[id]/index.ts
new file mode 100644
index 00000000..df443a70
--- /dev/null
+++ b/api/api/v1/roles/[id]/index.ts
@@ -0,0 +1,191 @@
+import { apiRoute, auth, handleZodError } from "@/api";
+import { Role as RoleSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Role } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) => {
+ app.get(
+ "/api/v1/roles/:id",
+ describeRoute({
+ summary: "Get role data",
+ tags: ["Roles"],
+ responses: {
+ 200: {
+ description: "Role",
+ content: {
+ "application/json": {
+ schema: resolver(RoleSchema),
+ },
+ },
+ },
+ 404: ApiError.roleNotFound().schema,
+ 403: ApiError.forbidden().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ }),
+ validator("param", z.object({ id: z.string().uuid() }), handleZodError),
+ async (context) => {
+ const { id } = context.req.valid("param");
+
+ const role = await Role.fromId(id);
+
+ if (!role) {
+ throw ApiError.roleNotFound();
+ }
+
+ return context.json(role.toApi(), 200);
+ },
+ );
+
+ app.patch(
+ "/api/v1/roles/:id",
+ describeRoute({
+ summary: "Update role data",
+ tags: ["Roles"],
+ responses: {
+ 204: {
+ description: "Role updated",
+ },
+ 404: ApiError.roleNotFound().schema,
+ 403: ApiError.forbidden().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageRoles],
+ }),
+ validator(
+ "param",
+ z.object({
+ id: z.string().uuid(),
+ }),
+ handleZodError,
+ ),
+ validator(
+ "json",
+ RoleSchema.omit({ id: true }).partial(),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { id } = context.req.valid("param");
+ const { permissions, priority, description, icon, name, visible } =
+ context.req.valid("json");
+
+ const role = await Role.fromId(id);
+
+ if (!role) {
+ throw ApiError.roleNotFound();
+ }
+
+ // Priority check
+ const userRoles = await Role.getUserRoles(
+ user.id,
+ user.data.isAdmin,
+ );
+
+ const userHighestRole = userRoles.reduce((prev, current) =>
+ prev.data.priority > current.data.priority ? prev : current,
+ );
+
+ if (role.data.priority > userHighestRole.data.priority) {
+ throw new ApiError(
+ 403,
+ "Forbidden",
+ `User with highest role priority ${userHighestRole.data.priority} cannot edit role with priority ${role.data.priority}`,
+ );
+ }
+
+ // If updating role permissions, the user must already have the permissions they wish to add/remove
+ if (permissions) {
+ const userPermissions = user.getAllPermissions();
+ const hasPermissions = (
+ permissions as unknown as RolePermission[]
+ ).every((p) => userPermissions.includes(p));
+
+ if (!hasPermissions) {
+ throw new ApiError(
+ 403,
+ "Forbidden",
+ "User cannot add or remove permissions they do not have",
+ );
+ }
+ }
+
+ await role.update({
+ permissions: permissions as unknown as RolePermission[],
+ priority,
+ description,
+ icon,
+ name,
+ visible,
+ });
+
+ return context.body(null, 204);
+ },
+ );
+
+ app.delete(
+ "/api/v1/roles/:id",
+ describeRoute({
+ summary: "Delete role",
+ tags: ["Roles"],
+ responses: {
+ 204: {
+ description: "Role deleted",
+ },
+ 404: ApiError.roleNotFound().schema,
+ 403: ApiError.forbidden().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageRoles],
+ }),
+ validator(
+ "param",
+ z.object({
+ id: z.string().uuid(),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { id } = context.req.valid("param");
+
+ const role = await Role.fromId(id);
+
+ if (!role) {
+ throw ApiError.roleNotFound();
+ }
+
+ // Priority check
+ const userRoles = await Role.getUserRoles(
+ user.id,
+ user.data.isAdmin,
+ );
+
+ const userHighestRole = userRoles.reduce((prev, current) =>
+ prev.data.priority > current.data.priority ? prev : current,
+ );
+
+ if (role.data.priority > userHighestRole.data.priority) {
+ throw new ApiError(
+ 403,
+ "Forbidden",
+ `User with highest role priority ${userHighestRole.data.priority} cannot delete role with priority ${role.data.priority}`,
+ );
+ }
+
+ await role.delete();
+
+ return context.body(null, 204);
+ },
+ );
+});
diff --git a/api/api/v1/roles/index.ts b/api/api/v1/roles/index.ts
index cb7cdf81..343de167 100644
--- a/api/api/v1/roles/index.ts
+++ b/api/api/v1/roles/index.ts
@@ -1,127 +1,119 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import { Role as RoleSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Role } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const routeGet = createRoute({
- method: "get",
- path: "/api/v1/roles",
- summary: "Get all roles",
- tags: ["Roles"],
- middleware: [
+export default apiRoute((app) => {
+ app.get(
+ "/api/v1/roles",
+ describeRoute({
+ summary: "Get all roles",
+ tags: ["Roles"],
+ responses: {
+ 200: {
+ description: "List of all roles",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(RoleSchema)),
+ },
+ },
+ },
+ },
+ }),
auth({
auth: true,
}),
- ] as const,
- responses: {
- 200: {
- description: "List of all roles",
- content: {
- "application/json": {
- schema: z.array(RoleSchema),
+ async (context) => {
+ const roles = await Role.getAll();
+
+ return context.json(
+ roles.map((r) => r.toApi()),
+ 200,
+ );
+ },
+ );
+
+ app.post(
+ "/api/v1/roles",
+ describeRoute({
+ summary: "Create a new role",
+ tags: ["Roles"],
+ responses: {
+ 201: {
+ description: "Role created",
+ content: {
+ "application/json": {
+ schema: resolver(RoleSchema),
+ },
+ },
+ },
+ 403: {
+ description: "Forbidden",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
},
},
- },
- },
-});
-
-const routePost = createRoute({
- method: "post",
- path: "/api/v1/roles",
- summary: "Create a new role",
- tags: ["Roles"],
- middleware: [
+ }),
auth({
auth: true,
permissions: [RolePermission.ManageRoles],
}),
- ] as const,
- request: {
- body: {
- content: {
- "application/json": {
- schema: RoleSchema.omit({ id: true }),
- },
- },
- },
- },
- responses: {
- 201: {
- description: "Role created",
- content: {
- "application/json": {
- schema: RoleSchema,
- },
- },
- },
+ validator("json", RoleSchema.omit({ id: true }), handleZodError),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { description, icon, name, permissions, priority, visible } =
+ context.req.valid("json");
- 403: {
- description: "Forbidden",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) => {
- app.openapi(routeGet, async (context) => {
- const roles = await Role.getAll();
-
- return context.json(
- roles.map((r) => r.toApi()),
- 200,
- );
- });
-
- app.openapi(routePost, async (context) => {
- const { user } = context.get("auth");
- const { description, icon, name, permissions, priority, visible } =
- context.req.valid("json");
-
- // Priority check
- const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
-
- const userHighestRole = userRoles.reduce((prev, current) =>
- prev.data.priority > current.data.priority ? prev : current,
- );
-
- if (priority > userHighestRole.data.priority) {
- throw new ApiError(
- 403,
- "Cannot create role with higher priority than your own",
+ // Priority check
+ const userRoles = await Role.getUserRoles(
+ user.id,
+ user.data.isAdmin,
);
- }
- // When adding new permissions, the user must already have the permissions they wish to add
- if (permissions) {
- const userPermissions = user.getAllPermissions();
- const hasPermissions = (
- permissions as unknown as RolePermission[]
- ).every((p) => userPermissions.includes(p));
+ const userHighestRole = userRoles.reduce((prev, current) =>
+ prev.data.priority > current.data.priority ? prev : current,
+ );
- if (!hasPermissions) {
+ if (priority > userHighestRole.data.priority) {
throw new ApiError(
403,
- "Cannot create role with permissions you do not have",
- `Forbidden permissions: ${permissions.join(", ")}`,
+ "Cannot create role with higher priority than your own",
);
}
- }
- const newRole = await Role.insert({
- description,
- icon,
- name,
- permissions: permissions as unknown as RolePermission[],
- priority,
- visible,
- });
+ // When adding new permissions, the user must already have the permissions they wish to add
+ if (permissions) {
+ const userPermissions = user.getAllPermissions();
+ const hasPermissions = (
+ permissions as unknown as RolePermission[]
+ ).every((p) => userPermissions.includes(p));
- return context.json(newRole.toApi(), 201);
- });
+ if (!hasPermissions) {
+ throw new ApiError(
+ 403,
+ "Cannot create role with permissions you do not have",
+ `Forbidden permissions: ${permissions.join(", ")}`,
+ );
+ }
+ }
+
+ const newRole = await Role.insert({
+ description,
+ icon,
+ name,
+ permissions: permissions as unknown as RolePermission[],
+ priority,
+ visible,
+ });
+
+ return context.json(newRole.toApi(), 201);
+ },
+ );
});
diff --git a/api/api/v1/statuses/:id/context.ts b/api/api/v1/statuses/:id/context.ts
deleted file mode 100644
index 992fa3ce..00000000
--- a/api/api/v1/statuses/:id/context.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { apiRoute, auth, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Context as ContextSchema,
- Status as StatusSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/statuses/{id}/context",
- summary: "Get parent and child statuses in context",
- description: "View statuses above and below this status in the thread.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#context",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: false,
- permissions: [RolePermission.ViewNotes],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Status parent and children",
- content: {
- "application/json": {
- schema: ContextSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const note = context.get("note");
-
- const ancestors = await note.getAncestors(user ?? null);
-
- const descendants = await note.getDescendants(user ?? null);
-
- return context.json(
- {
- ancestors: await Promise.all(
- ancestors.map((status) => status.toApi(user)),
- ),
- descendants: await Promise.all(
- descendants.map((status) => status.toApi(user)),
- ),
- },
- 200,
- );
- }),
-);
diff --git a/api/api/v1/statuses/:id/favourite.ts b/api/api/v1/statuses/:id/favourite.ts
deleted file mode 100644
index bb89ee52..00000000
--- a/api/api/v1/statuses/:id/favourite.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { apiRoute, auth, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/statuses/{id}/favourite",
- summary: "Favourite a status",
- description: "Add a status to your favourites list.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#favourite",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnLikes,
- RolePermission.ViewNotes,
- ],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Status favourited or was already favourited",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const note = context.get("note");
-
- await user.like(note);
-
- await note.reload(user.id);
-
- return context.json(await note.toApi(user), 200);
- }),
-);
diff --git a/api/api/v1/statuses/:id/favourited_by.ts b/api/api/v1/statuses/:id/favourited_by.ts
deleted file mode 100644
index c1323ec0..00000000
--- a/api/api/v1/statuses/:id/favourited_by.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { apiRoute, auth, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Status as StatusSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Timeline } from "@versia/kit/db";
-import { Users } from "@versia/kit/tables";
-import { and, gt, gte, lt, sql } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/statuses/{id}/favourited_by",
- summary: "See who favourited a status",
- description: "View who favourited a given status.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#favourited_by",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ViewNotes,
- RolePermission.ViewNoteLikes,
- ],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- query: z.object({
- max_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: AccountSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
- description: "Maximum number of results to return.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "A list of accounts who favourited the status",
- content: {
- "application/json": {
- schema: z.array(AccountSchema),
- },
- },
- headers: z.object({
- link: z
- .string()
- .optional()
- .openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
- }),
- }),
- },
- 404: ApiError.noteNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, since_id, min_id, limit } = context.req.valid("query");
- const note = context.get("note");
-
- const { objects, link } = await Timeline.getUserTimeline(
- and(
- max_id ? lt(Users.id, max_id) : undefined,
- since_id ? gte(Users.id, since_id) : undefined,
- min_id ? gt(Users.id, min_id) : undefined,
- sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${note.id} AND "Likes"."likerId" = ${Users.id})`,
- ),
- limit,
- new URL(context.req.url),
- );
-
- return context.json(
- objects.map((user) => user.toApi()),
- 200,
- {
- Link: link,
- },
- );
- }),
-);
diff --git a/api/api/v1/statuses/:id/index.ts b/api/api/v1/statuses/:id/index.ts
deleted file mode 100644
index 1b4d2aba..00000000
--- a/api/api/v1/statuses/:id/index.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-import { apiRoute, auth, jsonOrForm, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Attachment as AttachmentSchema,
- PollOption,
- Status as StatusSchema,
- StatusSource as StatusSourceSchema,
- zBoolean,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Media } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-import { config } from "~/config.ts";
-
-const schema = z
- .object({
- status: StatusSourceSchema.shape.text.optional().openapi({
- description:
- "The text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.",
- }),
- /* Versia Server API Extension */
- content_type: z
- .enum(["text/plain", "text/html", "text/markdown"])
- .default("text/plain")
- .openapi({
- description: "Content-Type of the status text.",
- example: "text/markdown",
- }),
- media_ids: z
- .array(AttachmentSchema.shape.id)
- .max(config.validation.notes.max_attachments)
- .default([])
- .openapi({
- description:
- "Include Attachment IDs to be attached as media. If provided, status becomes optional, and poll cannot be used.",
- }),
- spoiler_text: StatusSourceSchema.shape.spoiler_text.optional().openapi({
- description:
- "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.",
- }),
- sensitive: zBoolean.default(false).openapi({
- description: "Mark status and attached media as sensitive?",
- }),
- language: StatusSchema.shape.language.optional(),
- "poll[options]": z
- .array(PollOption.shape.title)
- .max(config.validation.polls.max_options)
- .optional()
- .openapi({
- description:
- "Possible answers to the poll. If provided, media_ids cannot be used, and poll[expires_in] must be provided.",
- }),
- "poll[expires_in]": z.coerce
- .number()
- .int()
- .min(config.validation.polls.min_duration_seconds)
- .max(config.validation.polls.max_duration_seconds)
- .optional()
- .openapi({
- description:
- "Duration that the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided.",
- }),
- "poll[multiple]": zBoolean.optional().openapi({
- description: "Allow multiple choices?",
- }),
- "poll[hide_totals]": zBoolean.optional().openapi({
- description: "Hide vote counts until the poll ends?",
- }),
- })
- .refine(
- (obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]),
- "Cannot attach poll to media",
- );
-
-const routeGet = createRoute({
- method: "get",
- path: "/api/v1/statuses/{id}",
- summary: "View a single status",
- description: "Obtain information about a status.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#get",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: false,
- permissions: [RolePermission.ViewNotes],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Status",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- },
-});
-
-const routeDelete = createRoute({
- method: "delete",
- path: "/api/v1/statuses/{id}",
- summary: "Delete a status",
- description: "Delete one of your own statuses.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#delete",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnNotes,
- RolePermission.ViewNotes,
- ],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description:
- "Note the special properties text and poll or media_attachments which may be used to repost the status, e.g. in case of delete-and-redraft functionality.",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-const routePut = createRoute({
- method: "put",
- path: "/api/v1/statuses/{id}",
- summary: "Edit a status",
- description:
- "Edit a given status to change its text, sensitivity, media attachments, or poll. Note that editing a poll’s options will reset the votes.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#edit",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnNotes,
- RolePermission.ViewNotes,
- ],
- }),
- jsonOrForm(),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- body: {
- content: {
- "application/json": {
- schema,
- },
- "application/x-www-form-urlencoded": {
- schema,
- },
- "multipart/form-data": {
- schema,
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Status has been successfully edited.",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 403: ApiError.forbidden().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) => {
- app.openapi(routeGet, async (context) => {
- const { user } = context.get("auth");
- const note = context.get("note");
-
- return context.json(await note.toApi(user), 200);
- });
-
- app.openapi(routeDelete, async (context) => {
- const { user } = context.get("auth");
- const note = context.get("note");
-
- if (note.author.id !== user.id) {
- throw ApiError.forbidden();
- }
-
- // TODO: Delete and redraft
- await note.delete();
-
- await user.federateToFollowers(note.deleteToVersia());
-
- return context.json(await note.toApi(user), 200);
- });
-
- app.openapi(routePut, async (context) => {
- const { user } = context.get("auth");
- const note = context.get("note");
-
- if (note.author.id !== user.id) {
- throw ApiError.forbidden();
- }
-
- // TODO: Polls
- const {
- status: statusText,
- content_type,
- media_ids,
- spoiler_text,
- sensitive,
- } = context.req.valid("json");
-
- const foundAttachments =
- media_ids.length > 0 ? await Media.fromIds(media_ids) : [];
-
- if (foundAttachments.length !== media_ids.length) {
- throw new ApiError(
- 422,
- "Some attachments referenced by media_ids not found",
- );
- }
-
- const newNote = await note.updateFromData({
- author: user,
- content: statusText
- ? {
- [content_type]: {
- content: statusText,
- remote: false,
- },
- }
- : undefined,
- isSensitive: sensitive,
- spoilerText: spoiler_text,
- mediaAttachments: foundAttachments,
- });
-
- return context.json(await newNote.toApi(user), 200);
- });
-});
diff --git a/api/api/v1/statuses/:id/pin.ts b/api/api/v1/statuses/:id/pin.ts
deleted file mode 100644
index cb92121e..00000000
--- a/api/api/v1/statuses/:id/pin.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { apiRoute, auth, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { db } from "@versia/kit/db";
-import type { SQL } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/statuses/{id}/pin",
- summary: "Pin status to profile",
- description:
- "Feature one of your own public statuses at the top of your profile.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#pin",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnNotes,
- RolePermission.ViewNotes,
- ],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description:
- "Status pinned. Note the status is not a reblog and its authoring account is your own.",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 403: ApiError.forbidden().schema,
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const note = context.get("note");
-
- if (note.author.id !== user.id) {
- throw ApiError.forbidden();
- }
-
- if (
- await db.query.UserToPinnedNotes.findFirst({
- where: (userPinnedNote, { and, eq }): SQL | undefined =>
- and(
- eq(userPinnedNote.noteId, note.data.id),
- eq(userPinnedNote.userId, user.id),
- ),
- })
- ) {
- return context.json(await note.toApi(user), 200);
- }
-
- await user.pin(note);
-
- return context.json(await note.toApi(user), 200);
- }),
-);
diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts
deleted file mode 100644
index 7e68bb07..00000000
--- a/api/api/v1/statuses/:id/reblog.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { apiRoute, auth, jsonOrForm, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Note } from "@versia/kit/db";
-import { Notes } from "@versia/kit/tables";
-import { and, eq } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/statuses/{id}/reblog",
- summary: "Boost a status",
- description: "Reshare a status on your own profile.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#boost",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnBoosts,
- RolePermission.ViewNotes,
- ],
- }),
- jsonOrForm(),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- body: {
- content: {
- "application/json": {
- schema: z.object({
- visibility:
- StatusSchema.shape.visibility.default("public"),
- }),
- },
- "application/x-www-form-urlencoded": {
- schema: z.object({
- visibility:
- StatusSchema.shape.visibility.default("public"),
- }),
- },
- "multipart/form-data": {
- schema: z.object({
- visibility:
- StatusSchema.shape.visibility.default("public"),
- }),
- },
- },
- },
- },
- responses: {
- 200: {
- description:
- "Status has been reblogged. Note that the top-level ID has changed. The ID of the boosted status is now inside the reblog property. The top-level ID is the ID of the reblog itself. Also note that reblogs cannot be pinned.",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { visibility } = context.req.valid("json");
- const { user } = context.get("auth");
- const note = context.get("note");
-
- const existingReblog = await Note.fromSql(
- and(eq(Notes.authorId, user.id), eq(Notes.reblogId, note.data.id)),
- );
-
- if (existingReblog) {
- return context.json(await existingReblog.toApi(user), 200);
- }
-
- const newReblog = await Note.insert({
- authorId: user.id,
- reblogId: note.data.id,
- visibility,
- sensitive: false,
- updatedAt: new Date().toISOString(),
- applicationId: null,
- });
-
- // Refetch the note *again* to get the proper value of .reblogged
- const finalNewReblog = await Note.fromId(newReblog.id, user?.id);
-
- if (!finalNewReblog) {
- throw new Error("Failed to reblog");
- }
-
- if (note.author.isLocal() && user.isLocal()) {
- await note.author.notify("reblog", user, newReblog);
- }
-
- return context.json(await finalNewReblog.toApi(user), 200);
- }),
-);
diff --git a/api/api/v1/statuses/:id/reblogged_by.ts b/api/api/v1/statuses/:id/reblogged_by.ts
deleted file mode 100644
index 3f76dec8..00000000
--- a/api/api/v1/statuses/:id/reblogged_by.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { apiRoute, auth, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Account as AccountSchema,
- Status as StatusSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Timeline } from "@versia/kit/db";
-import { Users } from "@versia/kit/tables";
-import { and, gt, gte, lt, sql } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/statuses/{id}/reblogged_by",
- summary: "See who boosted a status",
- description: "View who boosted a given status.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#reblogged_by",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ViewNotes,
- RolePermission.ViewNoteBoosts,
- ],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- query: z.object({
- max_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: AccountSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: AccountSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
- description: "Maximum number of results to return.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "A list of accounts that boosted the status",
- content: {
- "application/json": {
- schema: z.array(AccountSchema),
- },
- },
- headers: z.object({
- link: z
- .string()
- .optional()
- .openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
- }),
- }),
- },
- 404: ApiError.noteNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, min_id, since_id, limit } = context.req.valid("query");
- const note = context.get("note");
-
- const { objects, link } = await Timeline.getUserTimeline(
- and(
- max_id ? lt(Users.id, max_id) : undefined,
- since_id ? gte(Users.id, since_id) : undefined,
- min_id ? gt(Users.id, min_id) : undefined,
- sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${note.id} AND "Notes"."authorId" = ${Users.id})`,
- ),
- limit,
- new URL(context.req.url),
- );
-
- return context.json(
- objects.map((user) => user.toApi()),
- 200,
- {
- Link: link,
- },
- );
- }),
-);
diff --git a/api/api/v1/statuses/:id/source.ts b/api/api/v1/statuses/:id/source.ts
deleted file mode 100644
index 78e027fe..00000000
--- a/api/api/v1/statuses/:id/source.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { apiRoute, auth, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Status as StatusSchema,
- StatusSource as StatusSourceSchema,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "get",
- path: "/api/v1/statuses/{id}/source",
- summary: "View status source",
- description:
- "Obtain the source properties for a status so that it can be edited.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#source",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnNotes,
- RolePermission.ViewNotes,
- ],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Status source",
- content: {
- "application/json": {
- schema: StatusSourceSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, (context) => {
- const note = context.get("note");
-
- return context.json(
- {
- id: note.id,
- // TODO: Give real source for spoilerText
- spoiler_text: note.data.spoilerText,
- text: note.data.contentSource,
- },
- 200,
- );
- }),
-);
diff --git a/api/api/v1/statuses/:id/unfavourite.ts b/api/api/v1/statuses/:id/unfavourite.ts
deleted file mode 100644
index edd46d21..00000000
--- a/api/api/v1/statuses/:id/unfavourite.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { apiRoute, auth, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/statuses/{id}/unfavourite",
- summary: "Undo favourite of a status",
- description: "Remove a status from your favourites list.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#unfavourite",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnNotes,
- RolePermission.ViewNotes,
- ],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Status unfavourited or was already not favourited",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const note = context.get("note");
-
- await user.unlike(note);
-
- await note.reload(user.id);
-
- return context.json(await note.toApi(user), 200);
- }),
-);
diff --git a/api/api/v1/statuses/:id/unpin.ts b/api/api/v1/statuses/:id/unpin.ts
deleted file mode 100644
index e516d7c4..00000000
--- a/api/api/v1/statuses/:id/unpin.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { apiRoute, auth, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/statuses/{id}/unpin",
- summary: "Unpin status from profile",
- description: "Unfeature a status from the top of your profile.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#unpin",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnNotes,
- RolePermission.ViewNotes,
- ],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Status unpinned, or was already not pinned",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 403: ApiError.forbidden().schema,
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const note = context.get("note");
-
- if (note.author.id !== user.id) {
- throw ApiError.forbidden();
- }
-
- await user.unpin(note);
-
- if (!note) {
- throw ApiError.noteNotFound();
- }
-
- return context.json(await note.toApi(user), 200);
- }),
-);
diff --git a/api/api/v1/statuses/:id/unreblog.ts b/api/api/v1/statuses/:id/unreblog.ts
deleted file mode 100644
index 2b8e53c1..00000000
--- a/api/api/v1/statuses/:id/unreblog.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { apiRoute, auth, withNoteParam } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { Note } from "@versia/kit/db";
-import { Notes } from "@versia/kit/tables";
-import { and, eq } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-
-const route = createRoute({
- method: "post",
- path: "/api/v1/statuses/{id}/unreblog",
- summary: "Undo boost of a status",
- description: "Undo a reshare of a status.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#unreblog",
- },
- tags: ["Statuses"],
- middleware: [
- auth({
- auth: true,
- permissions: [
- RolePermission.ManageOwnNotes,
- RolePermission.ViewNotes,
- ],
- }),
- withNoteParam,
- ] as const,
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Status unboosted or was already not boosted",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 404: ApiError.noteNotFound().schema,
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { id } = context.req.valid("param");
- const { user } = context.get("auth");
- const note = context.get("note");
-
- const existingReblog = await Note.fromSql(
- and(eq(Notes.authorId, user.id), eq(Notes.reblogId, note.data.id)),
- undefined,
- user?.id,
- );
-
- if (!existingReblog) {
- return context.json(await note.toApi(user), 200);
- }
-
- await existingReblog.delete();
-
- await user.federateToFollowers(existingReblog.deleteToVersia());
-
- const newNote = await Note.fromId(id, user.id);
-
- if (!newNote) {
- throw ApiError.noteNotFound();
- }
-
- return context.json(await newNote.toApi(user), 200);
- }),
-);
diff --git a/api/api/v1/statuses/:id/context.test.ts b/api/api/v1/statuses/[id]/context.test.ts
similarity index 100%
rename from api/api/v1/statuses/:id/context.test.ts
rename to api/api/v1/statuses/[id]/context.test.ts
diff --git a/api/api/v1/statuses/[id]/context.ts b/api/api/v1/statuses/[id]/context.ts
new file mode 100644
index 00000000..05c82af1
--- /dev/null
+++ b/api/api/v1/statuses/[id]/context.ts
@@ -0,0 +1,58 @@
+import { apiRoute, auth, withNoteParam } from "@/api";
+import { Context as ContextSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/statuses/:id/context",
+ describeRoute({
+ summary: "Get parent and child statuses in context",
+ description:
+ "View statuses above and below this status in the thread.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#context",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description: "Status parent and children",
+ content: {
+ "application/json": {
+ schema: resolver(ContextSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: false,
+ permissions: [RolePermission.ViewNotes],
+ }),
+ withNoteParam,
+ async (context) => {
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ const ancestors = await note.getAncestors(user ?? null);
+
+ const descendants = await note.getDescendants(user ?? null);
+
+ return context.json(
+ {
+ ancestors: await Promise.all(
+ ancestors.map((status) => status.toApi(user)),
+ ),
+ descendants: await Promise.all(
+ descendants.map((status) => status.toApi(user)),
+ ),
+ },
+ 200,
+ );
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/:id/favourite.test.ts b/api/api/v1/statuses/[id]/favourite.test.ts
similarity index 100%
rename from api/api/v1/statuses/:id/favourite.test.ts
rename to api/api/v1/statuses/[id]/favourite.test.ts
diff --git a/api/api/v1/statuses/[id]/favourite.ts b/api/api/v1/statuses/[id]/favourite.ts
new file mode 100644
index 00000000..4344225f
--- /dev/null
+++ b/api/api/v1/statuses/[id]/favourite.ts
@@ -0,0 +1,50 @@
+import { apiRoute, auth, withNoteParam } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/statuses/:id/favourite",
+ describeRoute({
+ summary: "Favourite a status",
+ description: "Add a status to your favourites list.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#favourite",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description: "Status favourited or was already favourited",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnLikes,
+ RolePermission.ViewNotes,
+ ],
+ }),
+ withNoteParam,
+ async (context) => {
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ await user.like(note);
+
+ await note.reload(user.id);
+
+ return context.json(await note.toApi(user), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/:id/favourited_by.test.ts b/api/api/v1/statuses/[id]/favourited_by.test.ts
similarity index 100%
rename from api/api/v1/statuses/:id/favourited_by.test.ts
rename to api/api/v1/statuses/[id]/favourited_by.test.ts
diff --git a/api/api/v1/statuses/[id]/favourited_by.ts b/api/api/v1/statuses/[id]/favourited_by.ts
new file mode 100644
index 00000000..c33859aa
--- /dev/null
+++ b/api/api/v1/statuses/[id]/favourited_by.ts
@@ -0,0 +1,113 @@
+import { apiRoute, auth, handleZodError, withNoteParam } from "@/api";
+import { Account as AccountSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Timeline } from "@versia/kit/db";
+import { Users } from "@versia/kit/tables";
+import { and, gt, gte, lt, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/statuses/:id/favourited_by",
+ describeRoute({
+ summary: "See who favourited a status",
+ description: "View who favourited a given status.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#favourited_by",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description: "A list of accounts who favourited the status",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(AccountSchema)),
+ },
+ },
+ headers: z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ },
+ 404: ApiError.noteNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ViewNotes,
+ RolePermission.ViewNoteLikes,
+ ],
+ }),
+ withNoteParam,
+ validator(
+ "query",
+ z.object({
+ max_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(80)
+ .default(40)
+ .openapi({
+ description: "Maximum number of results to return.",
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { max_id, since_id, min_id, limit } =
+ context.req.valid("query");
+ const note = context.get("note");
+
+ const { objects, link } = await Timeline.getUserTimeline(
+ and(
+ max_id ? lt(Users.id, max_id) : undefined,
+ since_id ? gte(Users.id, since_id) : undefined,
+ min_id ? gt(Users.id, min_id) : undefined,
+ sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${note.id} AND "Likes"."likerId" = ${Users.id})`,
+ ),
+ limit,
+ new URL(context.req.url),
+ );
+
+ return context.json(
+ objects.map((user) => user.toApi()),
+ 200,
+ {
+ Link: link,
+ },
+ );
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/:id/index.test.ts b/api/api/v1/statuses/[id]/index.test.ts
similarity index 100%
rename from api/api/v1/statuses/:id/index.test.ts
rename to api/api/v1/statuses/[id]/index.test.ts
diff --git a/api/api/v1/statuses/[id]/index.ts b/api/api/v1/statuses/[id]/index.ts
new file mode 100644
index 00000000..b706ce54
--- /dev/null
+++ b/api/api/v1/statuses/[id]/index.ts
@@ -0,0 +1,246 @@
+import {
+ apiRoute,
+ auth,
+ handleZodError,
+ jsonOrForm,
+ withNoteParam,
+} from "@/api";
+import {
+ Attachment as AttachmentSchema,
+ PollOption,
+ Status as StatusSchema,
+ StatusSource as StatusSourceSchema,
+ zBoolean,
+} from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Media } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+import { config } from "~/config.ts";
+
+const schema = z
+ .object({
+ status: StatusSourceSchema.shape.text.optional().openapi({
+ description:
+ "The text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.",
+ }),
+ /* Versia Server API Extension */
+ content_type: z
+ .enum(["text/plain", "text/html", "text/markdown"])
+ .default("text/plain")
+ .openapi({
+ description: "Content-Type of the status text.",
+ example: "text/markdown",
+ }),
+ media_ids: z
+ .array(AttachmentSchema.shape.id)
+ .max(config.validation.notes.max_attachments)
+ .default([])
+ .openapi({
+ description:
+ "Include Attachment IDs to be attached as media. If provided, status becomes optional, and poll cannot be used.",
+ }),
+ spoiler_text: StatusSourceSchema.shape.spoiler_text.optional().openapi({
+ description:
+ "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.",
+ }),
+ sensitive: zBoolean.default(false).openapi({
+ description: "Mark status and attached media as sensitive?",
+ }),
+ language: StatusSchema.shape.language.optional(),
+ "poll[options]": z
+ .array(PollOption.shape.title)
+ .max(config.validation.polls.max_options)
+ .optional()
+ .openapi({
+ description:
+ "Possible answers to the poll. If provided, media_ids cannot be used, and poll[expires_in] must be provided.",
+ }),
+ "poll[expires_in]": z.coerce
+ .number()
+ .int()
+ .min(config.validation.polls.min_duration_seconds)
+ .max(config.validation.polls.max_duration_seconds)
+ .optional()
+ .openapi({
+ description:
+ "Duration that the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided.",
+ }),
+ "poll[multiple]": zBoolean.optional().openapi({
+ description: "Allow multiple choices?",
+ }),
+ "poll[hide_totals]": zBoolean.optional().openapi({
+ description: "Hide vote counts until the poll ends?",
+ }),
+ })
+ .refine(
+ (obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]),
+ "Cannot attach poll to media",
+ );
+
+export default apiRoute((app) => {
+ app.get(
+ "/api/v1/statuses/:id",
+ describeRoute({
+ summary: "View a single status",
+ description: "Obtain information about a status.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#get",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description: "Status",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ },
+ }),
+ auth({
+ auth: false,
+ permissions: [RolePermission.ViewNotes],
+ }),
+ withNoteParam,
+ async (context) => {
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ return context.json(await note.toApi(user), 200);
+ },
+ );
+
+ app.delete(
+ "/api/v1/statuses/:id",
+ describeRoute({
+ summary: "Delete a status",
+ description: "Delete one of your own statuses.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#delete",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description:
+ "Note the special properties text and poll or media_attachments which may be used to repost the status, e.g. in case of delete-and-redraft functionality.",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnNotes,
+ RolePermission.ViewNotes,
+ ],
+ }),
+ withNoteParam,
+ async (context) => {
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ if (note.author.id !== user.id) {
+ throw ApiError.forbidden();
+ }
+
+ // TODO: Delete and redraft
+ await note.delete();
+
+ await user.federateToFollowers(note.deleteToVersia());
+
+ return context.json(await note.toApi(user), 200);
+ },
+ );
+
+ app.put(
+ "/api/v1/statuses/:id",
+ describeRoute({
+ summary: "Edit a status",
+ description:
+ "Edit a given status to change its text, sensitivity, media attachments, or poll. Note that editing a poll’s options will reset the votes.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#edit",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description: "Status has been successfully edited.",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 403: ApiError.forbidden().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnNotes,
+ RolePermission.ViewNotes,
+ ],
+ }),
+ jsonOrForm(),
+ withNoteParam,
+ validator("json", schema, handleZodError),
+ async (context) => {
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ if (note.author.id !== user.id) {
+ throw ApiError.forbidden();
+ }
+
+ // TODO: Polls
+ const {
+ status: statusText,
+ content_type,
+ media_ids,
+ spoiler_text,
+ sensitive,
+ } = context.req.valid("json");
+
+ const foundAttachments =
+ media_ids.length > 0 ? await Media.fromIds(media_ids) : [];
+
+ if (foundAttachments.length !== media_ids.length) {
+ throw new ApiError(
+ 422,
+ "Some attachments referenced by media_ids not found",
+ );
+ }
+
+ const newNote = await note.updateFromData({
+ author: user,
+ content: statusText
+ ? {
+ [content_type]: {
+ content: statusText,
+ remote: false,
+ },
+ }
+ : undefined,
+ isSensitive: sensitive,
+ spoilerText: spoiler_text,
+ mediaAttachments: foundAttachments,
+ });
+
+ return context.json(await newNote.toApi(user), 200);
+ },
+ );
+});
diff --git a/api/api/v1/statuses/[id]/pin.ts b/api/api/v1/statuses/[id]/pin.ts
new file mode 100644
index 00000000..8ebddbba
--- /dev/null
+++ b/api/api/v1/statuses/[id]/pin.ts
@@ -0,0 +1,70 @@
+import { apiRoute, auth, withNoteParam } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { db } from "@versia/kit/db";
+import type { SQL } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/statuses/:id/pin",
+ describeRoute({
+ summary: "Pin status to profile",
+ description:
+ "Feature one of your own public statuses at the top of your profile.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#pin",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description:
+ "Status pinned. Note the status is not a reblog and its authoring account is your own.",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 403: ApiError.forbidden().schema,
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnNotes,
+ RolePermission.ViewNotes,
+ ],
+ }),
+ withNoteParam,
+ async (context) => {
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ if (note.author.id !== user.id) {
+ throw ApiError.forbidden();
+ }
+
+ if (
+ await db.query.UserToPinnedNotes.findFirst({
+ where: (userPinnedNote, { and, eq }): SQL | undefined =>
+ and(
+ eq(userPinnedNote.noteId, note.data.id),
+ eq(userPinnedNote.userId, user.id),
+ ),
+ })
+ ) {
+ return context.json(await note.toApi(user), 200);
+ }
+
+ await user.pin(note);
+
+ return context.json(await note.toApi(user), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/:id/reblog.test.ts b/api/api/v1/statuses/[id]/reblog.test.ts
similarity index 100%
rename from api/api/v1/statuses/:id/reblog.test.ts
rename to api/api/v1/statuses/[id]/reblog.test.ts
diff --git a/api/api/v1/statuses/[id]/reblog.ts b/api/api/v1/statuses/[id]/reblog.ts
new file mode 100644
index 00000000..30dc7c59
--- /dev/null
+++ b/api/api/v1/statuses/[id]/reblog.ts
@@ -0,0 +1,91 @@
+import { apiRoute, auth, jsonOrForm, withNoteParam } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Note } from "@versia/kit/db";
+import { Notes } from "@versia/kit/tables";
+import { and, eq } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/statuses/:id/reblog",
+ describeRoute({
+ summary: "Boost a status",
+ description: "Reshare a status on your own profile.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#boost",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description:
+ "Status has been reblogged. Note that the top-level ID has changed. The ID of the boosted status is now inside the reblog property. The top-level ID is the ID of the reblog itself. Also note that reblogs cannot be pinned.",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnBoosts,
+ RolePermission.ViewNotes,
+ ],
+ }),
+ jsonOrForm(),
+ withNoteParam,
+ validator(
+ "json",
+ z.object({
+ visibility: StatusSchema.shape.visibility.default("public"),
+ }),
+ ),
+ async (context) => {
+ const { visibility } = context.req.valid("json");
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ const existingReblog = await Note.fromSql(
+ and(
+ eq(Notes.authorId, user.id),
+ eq(Notes.reblogId, note.data.id),
+ ),
+ );
+
+ if (existingReblog) {
+ return context.json(await existingReblog.toApi(user), 200);
+ }
+
+ const newReblog = await Note.insert({
+ authorId: user.id,
+ reblogId: note.data.id,
+ visibility,
+ sensitive: false,
+ updatedAt: new Date().toISOString(),
+ applicationId: null,
+ });
+
+ // Refetch the note *again* to get the proper value of .reblogged
+ const finalNewReblog = await Note.fromId(newReblog.id, user?.id);
+
+ if (!finalNewReblog) {
+ throw new Error("Failed to reblog");
+ }
+
+ if (note.author.isLocal() && user.isLocal()) {
+ await note.author.notify("reblog", user, newReblog);
+ }
+
+ return context.json(await finalNewReblog.toApi(user), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/:id/reblogged_by.test.ts b/api/api/v1/statuses/[id]/reblogged_by.test.ts
similarity index 100%
rename from api/api/v1/statuses/:id/reblogged_by.test.ts
rename to api/api/v1/statuses/[id]/reblogged_by.test.ts
diff --git a/api/api/v1/statuses/[id]/reblogged_by.ts b/api/api/v1/statuses/[id]/reblogged_by.ts
new file mode 100644
index 00000000..934d2d6f
--- /dev/null
+++ b/api/api/v1/statuses/[id]/reblogged_by.ts
@@ -0,0 +1,113 @@
+import { apiRoute, auth, handleZodError, withNoteParam } from "@/api";
+import { Account as AccountSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Timeline } from "@versia/kit/db";
+import { Users } from "@versia/kit/tables";
+import { and, gt, gte, lt, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/statuses/:id/reblogged_by",
+ describeRoute({
+ summary: "See who boosted a status",
+ description: "View who boosted a given status.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#reblogged_by",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description: "A list of accounts that boosted the status",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(AccountSchema)),
+ },
+ },
+ headers: z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ },
+ 404: ApiError.noteNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ViewNotes,
+ RolePermission.ViewNoteBoosts,
+ ],
+ }),
+ withNoteParam,
+ validator(
+ "query",
+ z.object({
+ max_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(80)
+ .default(40)
+ .openapi({
+ description: "Maximum number of results to return.",
+ }),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { max_id, min_id, since_id, limit } =
+ context.req.valid("query");
+ const note = context.get("note");
+
+ const { objects, link } = await Timeline.getUserTimeline(
+ and(
+ max_id ? lt(Users.id, max_id) : undefined,
+ since_id ? gte(Users.id, since_id) : undefined,
+ min_id ? gt(Users.id, min_id) : undefined,
+ sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${note.id} AND "Notes"."authorId" = ${Users.id})`,
+ ),
+ limit,
+ new URL(context.req.url),
+ );
+
+ return context.json(
+ objects.map((user) => user.toApi()),
+ 200,
+ {
+ Link: link,
+ },
+ );
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/[id]/source.ts b/api/api/v1/statuses/[id]/source.ts
new file mode 100644
index 00000000..29e3cf51
--- /dev/null
+++ b/api/api/v1/statuses/[id]/source.ts
@@ -0,0 +1,53 @@
+import { apiRoute, auth, withNoteParam } from "@/api";
+import { StatusSource as StatusSourceSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/statuses/:id/source",
+ describeRoute({
+ summary: "View status source",
+ description:
+ "Obtain the source properties for a status so that it can be edited.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#source",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description: "Status source",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSourceSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnNotes,
+ RolePermission.ViewNotes,
+ ],
+ }),
+ withNoteParam,
+ (context) => {
+ const note = context.get("note");
+ return context.json(
+ {
+ id: note.id,
+ // TODO: Give real source for spoilerText
+ spoiler_text: note.data.spoilerText,
+ text: note.data.contentSource,
+ },
+ 200,
+ );
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/:id/unfavourite.test.ts b/api/api/v1/statuses/[id]/unfavourite.test.ts
similarity index 100%
rename from api/api/v1/statuses/:id/unfavourite.test.ts
rename to api/api/v1/statuses/[id]/unfavourite.test.ts
diff --git a/api/api/v1/statuses/[id]/unfavourite.ts b/api/api/v1/statuses/[id]/unfavourite.ts
new file mode 100644
index 00000000..befb3fd7
--- /dev/null
+++ b/api/api/v1/statuses/[id]/unfavourite.ts
@@ -0,0 +1,51 @@
+import { apiRoute, auth, withNoteParam } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/statuses/:id/unfavourite",
+ describeRoute({
+ summary: "Undo favourite of a status",
+ description: "Remove a status from your favourites list.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#unfavourite",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description:
+ "Status unfavourited or was already not favourited",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnNotes,
+ RolePermission.ViewNotes,
+ ],
+ }),
+ withNoteParam,
+ async (context) => {
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ await user.unlike(note);
+
+ await note.reload(user.id);
+
+ return context.json(await note.toApi(user), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/[id]/unpin.ts b/api/api/v1/statuses/[id]/unpin.ts
new file mode 100644
index 00000000..47d08060
--- /dev/null
+++ b/api/api/v1/statuses/[id]/unpin.ts
@@ -0,0 +1,57 @@
+import { apiRoute, auth, withNoteParam } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/statuses/:id/unpin",
+ describeRoute({
+ summary: "Unpin status from profile",
+ description: "Unfeature a status from the top of your profile.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#unpin",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description: "Status unpinned, or was already not pinned",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 403: ApiError.forbidden().schema,
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnNotes,
+ RolePermission.ViewNotes,
+ ],
+ }),
+ withNoteParam,
+ async (context) => {
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ if (note.author.id !== user.id) {
+ throw ApiError.forbidden();
+ }
+
+ await user.unpin(note);
+
+ if (!note) {
+ throw ApiError.noteNotFound();
+ }
+
+ return context.json(await note.toApi(user), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/:id/unreblog.test.ts b/api/api/v1/statuses/[id]/unreblog.test.ts
similarity index 100%
rename from api/api/v1/statuses/:id/unreblog.test.ts
rename to api/api/v1/statuses/[id]/unreblog.test.ts
diff --git a/api/api/v1/statuses/[id]/unreblog.ts b/api/api/v1/statuses/[id]/unreblog.ts
new file mode 100644
index 00000000..bada9b4a
--- /dev/null
+++ b/api/api/v1/statuses/[id]/unreblog.ts
@@ -0,0 +1,72 @@
+import { apiRoute, auth, withNoteParam } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { Note } from "@versia/kit/db";
+import { Notes } from "@versia/kit/tables";
+import { and, eq } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/statuses/:id/unreblog",
+ describeRoute({
+ summary: "Undo boost of a status",
+ description: "Undo a reshare of a status.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#unreblog",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description: "Status unboosted or was already not boosted",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 404: ApiError.noteNotFound().schema,
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [
+ RolePermission.ManageOwnNotes,
+ RolePermission.ViewNotes,
+ ],
+ }),
+ withNoteParam,
+ async (context) => {
+ const { user } = context.get("auth");
+ const note = context.get("note");
+
+ const existingReblog = await Note.fromSql(
+ and(
+ eq(Notes.authorId, user.id),
+ eq(Notes.reblogId, note.data.id),
+ ),
+ undefined,
+ user?.id,
+ );
+
+ if (!existingReblog) {
+ return context.json(await note.toApi(user), 200);
+ }
+
+ await existingReblog.delete();
+
+ await user.federateToFollowers(existingReblog.deleteToVersia());
+
+ const newNote = await Note.fromId(note.data.id, user.id);
+
+ if (!newNote) {
+ throw ApiError.noteNotFound();
+ }
+
+ return context.json(await newNote.toApi(user), 200);
+ },
+ ),
+);
diff --git a/api/api/v1/statuses/index.test.ts b/api/api/v1/statuses/index.test.ts
index 202c8aea..57b43dc5 100644
--- a/api/api/v1/statuses/index.test.ts
+++ b/api/api/v1/statuses/index.test.ts
@@ -1,9 +1,9 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
-import type { z } from "@hono/zod-openapi";
import type { Status } from "@versia/client/schemas";
import { Media, db } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
+import type { z } from "zod";
import { config } from "~/config.ts";
import { generateClient, getTestUsers } from "~/tests/utils";
diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts
index 033215a9..b0a529a5 100644
--- a/api/api/v1/statuses/index.ts
+++ b/api/api/v1/statuses/index.ts
@@ -1,5 +1,4 @@
-import { apiRoute, auth, jsonOrForm } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import {
Attachment as AttachmentSchema,
PollOption,
@@ -9,6 +8,9 @@ import {
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media, Note } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
@@ -101,111 +103,99 @@ const schema = z
"Cannot attach poll to media",
);
-const route = createRoute({
- method: "post",
- path: "/api/v1/statuses",
- summary: "Post a new status",
- description: "Publish a status with the given parameters.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/statuses/#create",
- },
- tags: ["Statuses"],
- middleware: [
+export default apiRoute((app) =>
+ app.post(
+ "/api/v1/statuses",
+ describeRoute({
+ summary: "Post a new status",
+ description: "Publish a status with the given parameters.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/statuses/#create",
+ },
+ tags: ["Statuses"],
+ responses: {
+ 200: {
+ description:
+ "Status will be posted with chosen parameters.",
+ content: {
+ "application/json": {
+ schema: resolver(StatusSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
permissions: [RolePermission.ManageOwnNotes],
}),
jsonOrForm(),
- ] as const,
- request: {
- body: {
- content: {
- "application/json": {
- schema,
+ validator("json", schema, handleZodError),
+ async (context) => {
+ const { user, application } = context.get("auth");
+
+ const {
+ status,
+ media_ids,
+ in_reply_to_id,
+ quote_id,
+ sensitive,
+ spoiler_text,
+ visibility,
+ content_type,
+ local_only,
+ } = context.req.valid("json");
+
+ // Check if media attachments are all valid
+ const foundAttachments =
+ media_ids.length > 0 ? await Media.fromIds(media_ids) : [];
+
+ if (foundAttachments.length !== media_ids.length) {
+ throw new ApiError(
+ 422,
+ "Some attachments referenced by media_ids not found",
+ );
+ }
+
+ // Check that in_reply_to_id and quote_id are real posts if provided
+ if (in_reply_to_id && !(await Note.fromId(in_reply_to_id))) {
+ throw new ApiError(
+ 422,
+ "Note referenced by in_reply_to_id not found",
+ );
+ }
+
+ if (quote_id && !(await Note.fromId(quote_id))) {
+ throw new ApiError(
+ 422,
+ "Note referenced by quote_id not found",
+ );
+ }
+
+ const newNote = await Note.fromData({
+ author: user,
+ content: {
+ [content_type]: {
+ content: status ?? "",
+ remote: false,
+ },
},
- "application/x-www-form-urlencoded": {
- schema,
- },
- "multipart/form-data": {
- schema,
- },
- },
+ visibility,
+ isSensitive: sensitive ?? false,
+ spoilerText: spoiler_text ?? "",
+ mediaAttachments: foundAttachments,
+ replyId: in_reply_to_id ?? undefined,
+ quoteId: quote_id ?? undefined,
+ application: application ?? undefined,
+ });
+
+ if (!local_only) {
+ await newNote.federateToUsers();
+ }
+
+ return context.json(await newNote.toApi(user), 200);
},
- },
- responses: {
- 200: {
- description: "Status will be posted with chosen parameters.",
- content: {
- "application/json": {
- schema: StatusSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user, application } = context.get("auth");
-
- const {
- status,
- media_ids,
- in_reply_to_id,
- quote_id,
- sensitive,
- spoiler_text,
- visibility,
- content_type,
- local_only,
- } = context.req.valid("json");
-
- // Check if media attachments are all valid
- const foundAttachments =
- media_ids.length > 0 ? await Media.fromIds(media_ids) : [];
-
- if (foundAttachments.length !== media_ids.length) {
- throw new ApiError(
- 422,
- "Some attachments referenced by media_ids not found",
- );
- }
-
- // Check that in_reply_to_id and quote_id are real posts if provided
- if (in_reply_to_id && !(await Note.fromId(in_reply_to_id))) {
- throw new ApiError(
- 422,
- "Note referenced by in_reply_to_id not found",
- );
- }
-
- if (quote_id && !(await Note.fromId(quote_id))) {
- throw new ApiError(422, "Note referenced by quote_id not found");
- }
-
- const newNote = await Note.fromData({
- author: user,
- content: {
- [content_type]: {
- content: status ?? "",
- remote: false,
- },
- },
- visibility,
- isSensitive: sensitive ?? false,
- spoilerText: spoiler_text ?? "",
- mediaAttachments: foundAttachments,
- replyId: in_reply_to_id ?? undefined,
- quoteId: quote_id ?? undefined,
- application: application ?? undefined,
- });
-
- if (!local_only) {
- await newNote.federateToUsers();
- }
-
- return context.json(await newNote.toApi(user), 200);
- }),
+ ),
);
diff --git a/api/api/v1/timelines/home.ts b/api/api/v1/timelines/home.ts
index 0819f1e6..5f8090d9 100644
--- a/api/api/v1/timelines/home.ts
+++ b/api/api/v1/timelines/home.ts
@@ -1,22 +1,51 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/timelines/home",
- summary: "View home timeline",
- description: "View statuses from followed users and hashtags.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/timelines/#home",
- },
- tags: ["Timelines"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/timelines/home",
+ describeRoute({
+ summary: "View home timeline",
+ description: "View statuses from followed users and hashtags.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/timelines/#home",
+ },
+ tags: ["Timelines"],
+ responses: {
+ 200: {
+ description:
+ "Statuses in your home timeline will be returned",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(StatusSchema)),
+ },
+ },
+ headers: z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ },
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
permissions: [
@@ -26,91 +55,73 @@ const route = createRoute({
RolePermission.ViewPrivateTimelines,
],
}),
- ] as const,
- request: {
- query: z.object({
- max_id: StatusSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: StatusSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: StatusSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce.number().int().min(1).max(40).default(20).openapi({
- description: "Maximum number of results to return.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "Statuses in your home timeline will be returned",
- content: {
- "application/json": {
- schema: z.array(StatusSchema),
- },
- },
- headers: z.object({
- link: z
- .string()
- .optional()
+ validator(
+ "query",
+ z.object({
+ max_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(40)
+ .default(20)
.openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
+ description: "Maximum number of results to return.",
}),
}),
- },
- 422: ApiError.validationFailed().schema,
- },
-});
+ handleZodError,
+ ),
+ async (context) => {
+ const { max_id, since_id, min_id, limit } =
+ context.req.valid("query");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, since_id, min_id, limit } = context.req.valid("query");
+ const { user } = context.get("auth");
- const { user } = context.get("auth");
-
- const { objects, link } = await Timeline.getNoteTimeline(
- and(
+ const { objects, link } = await Timeline.getNoteTimeline(
and(
- max_id ? lt(Notes.id, max_id) : undefined,
- since_id ? gte(Notes.id, since_id) : undefined,
- min_id ? gt(Notes.id, min_id) : undefined,
- ),
- // Visibility check
- or(
- eq(Notes.authorId, user.id),
- sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${user.id})`,
and(
- sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`,
- inArray(Notes.visibility, ["public", "private"]),
+ max_id ? lt(Notes.id, max_id) : undefined,
+ since_id ? gte(Notes.id, since_id) : undefined,
+ min_id ? gt(Notes.id, min_id) : undefined,
),
- eq(Notes.visibility, "public"),
+ // Visibility check
+ or(
+ eq(Notes.authorId, user.id),
+ sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${user.id})`,
+ and(
+ sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`,
+ inArray(Notes.visibility, ["public", "private"]),
+ ),
+ eq(Notes.visibility, "public"),
+ ),
+ sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`,
),
- sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`,
- ),
- limit,
- new URL(context.req.url),
- user.id,
- );
+ limit,
+ new URL(context.req.url),
+ user.id,
+ );
- return context.json(
- await Promise.all(objects.map((note) => note.toApi(user))),
- 200,
- {
- Link: link,
- },
- );
- }),
+ return context.json(
+ await Promise.all(objects.map((note) => note.toApi(user))),
+ 200,
+ {
+ Link: link,
+ },
+ );
+ },
+ ),
);
diff --git a/api/api/v1/timelines/public.ts b/api/api/v1/timelines/public.ts
index 59c2fc06..848b17e7 100644
--- a/api/api/v1/timelines/public.ts
+++ b/api/api/v1/timelines/public.ts
@@ -1,22 +1,50 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import { Status as StatusSchema, zBoolean } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "get",
- path: "/api/v1/timelines/public",
- summary: "View public timeline",
- description: "View public statuses.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/timelines/#public",
- },
- tags: ["Timelines"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v1/timelines/public",
+ describeRoute({
+ summary: "View public timeline",
+ description: "View public statuses.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/timelines/#public",
+ },
+ tags: ["Timelines"],
+ responses: {
+ 200: {
+ description: "Public timeline",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(StatusSchema)),
+ },
+ },
+ headers: z.object({
+ link: z
+ .string()
+ .optional()
+ .openapi({
+ description:
+ "Links to the next and previous pages",
+ example:
+ '; rel="next", ; rel="prev"',
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
+ },
+ }),
+ }),
+ },
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: false,
permissions: [
@@ -25,123 +53,108 @@ const route = createRoute({
RolePermission.ViewPublicTimelines,
],
}),
- ] as const,
- request: {
- query: z
- .object({
- max_id: StatusSchema.shape.id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: StatusSchema.shape.id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: StatusSchema.shape.id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- local: zBoolean.default(false).openapi({
- description: "Show only local statuses?",
- }),
- remote: zBoolean.default(false).openapi({
- description: "Show only remote statuses?",
- }),
- only_media: zBoolean.default(false).openapi({
- description: "Show only statuses with media attached?",
- }),
- limit: z.coerce
- .number()
- .int()
- .min(1)
- .max(40)
- .default(20)
- .openapi({
- description: "Maximum number of results to return.",
+ validator(
+ "query",
+ z
+ .object({
+ max_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
- })
- .refine(
- (o) => !(o.local && o.remote),
- "'local' and 'remote' cannot be both true",
- ),
- },
- responses: {
- 200: {
- description: "Public timeline",
- content: {
- "application/json": {
- schema: z.array(StatusSchema),
+ since_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: StatusSchema.shape.id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ local: zBoolean.default(false).openapi({
+ description: "Show only local statuses?",
+ }),
+ remote: zBoolean.default(false).openapi({
+ description: "Show only remote statuses?",
+ }),
+ only_media: zBoolean.default(false).openapi({
+ description: "Show only statuses with media attached?",
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(40)
+ .default(20)
+ .openapi({
+ description: "Maximum number of results to return.",
+ }),
+ })
+ .refine(
+ (o) => !(o.local && o.remote),
+ "'local' and 'remote' cannot be both true",
+ ),
+ handleZodError,
+ ),
+ async (context) => {
+ const {
+ max_id,
+ since_id,
+ min_id,
+ limit,
+ local,
+ remote,
+ only_media,
+ } = context.req.valid("query");
+
+ const { user } = context.get("auth");
+
+ const { objects, link } = await Timeline.getNoteTimeline(
+ and(
+ max_id ? lt(Notes.id, max_id) : undefined,
+ since_id ? gte(Notes.id, since_id) : undefined,
+ min_id ? gt(Notes.id, min_id) : undefined,
+ remote
+ ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NOT NULL)`
+ : undefined,
+ local
+ ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)`
+ : undefined,
+ only_media
+ ? sql`EXISTS (SELECT 1 FROM "Medias" WHERE "Medias"."noteId" = ${Notes.id})`
+ : undefined,
+ user
+ ? sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['public'])`
+ : undefined,
+ // Visibility check
+ user
+ ? or(
+ eq(Notes.authorId, user.id),
+ sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${user.id})`,
+ and(
+ sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`,
+ inArray(Notes.visibility, [
+ "public",
+ "private",
+ ]),
+ ),
+ eq(Notes.visibility, "public"),
+ )
+ : eq(Notes.visibility, "public"),
+ ),
+ limit,
+ new URL(context.req.url),
+ user?.id,
+ );
+
+ return context.json(
+ await Promise.all(objects.map((note) => note.toApi(user))),
+ 200,
+ {
+ Link: link,
},
- },
- headers: z.object({
- link: z
- .string()
- .optional()
- .openapi({
- description: "Links to the next and previous pages",
- example:
- '; rel="next", ; rel="prev"',
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
- },
- }),
- }),
+ );
},
- 422: ApiError.validationFailed().schema,
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { max_id, since_id, min_id, limit, local, remote, only_media } =
- context.req.valid("query");
-
- const { user } = context.get("auth");
-
- const { objects, link } = await Timeline.getNoteTimeline(
- and(
- max_id ? lt(Notes.id, max_id) : undefined,
- since_id ? gte(Notes.id, since_id) : undefined,
- min_id ? gt(Notes.id, min_id) : undefined,
- remote
- ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NOT NULL)`
- : undefined,
- local
- ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)`
- : undefined,
- only_media
- ? sql`EXISTS (SELECT 1 FROM "Medias" WHERE "Medias"."noteId" = ${Notes.id})`
- : undefined,
- user
- ? sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['public'])`
- : undefined,
- // Visibility check
- user
- ? or(
- eq(Notes.authorId, user.id),
- sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${user.id})`,
- and(
- sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`,
- inArray(Notes.visibility, ["public", "private"]),
- ),
- eq(Notes.visibility, "public"),
- )
- : eq(Notes.visibility, "public"),
- ),
- limit,
- new URL(context.req.url),
- user?.id,
- );
-
- return context.json(
- await Promise.all(objects.map((note) => note.toApi(user))),
- 200,
- {
- Link: link,
- },
- );
- }),
+ ),
);
diff --git a/api/api/v2/filters/:id/index.ts b/api/api/v2/filters/:id/index.ts
deleted file mode 100644
index 147a3029..00000000
--- a/api/api/v2/filters/:id/index.ts
+++ /dev/null
@@ -1,311 +0,0 @@
-import { apiRoute, auth, jsonOrForm } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- FilterKeyword as FilterKeywordSchema,
- Filter as FilterSchema,
- zBoolean,
-} from "@versia/client/schemas";
-import { RolePermission } from "@versia/client/schemas";
-import { db } from "@versia/kit/db";
-import { FilterKeywords, Filters } from "@versia/kit/tables";
-import { type SQL, and, eq, inArray } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-
-const routeGet = createRoute({
- method: "get",
- path: "/api/v2/filters/{id}",
- summary: "View a specific filter",
- externalDocs: {
- url: "Obtain a single filter group owned by the current user.",
- },
- tags: ["Filters"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnFilters],
- }),
- ] as const,
- request: {
- params: z.object({
- id: FilterSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Filter",
- content: {
- "application/json": {
- schema: FilterSchema,
- },
- },
- },
- 404: {
- description: "Filter not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-const routePut = createRoute({
- method: "put",
- path: "/api/v2/filters/{id}",
- summary: "Update a filter",
- description: "Update a filter group with the given parameters.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/filters/#update",
- },
- tags: ["Filters"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnFilters],
- }),
- jsonOrForm(),
- ] as const,
- request: {
- params: z.object({
- id: FilterSchema.shape.id,
- }),
- body: {
- content: {
- "application/json": {
- schema: z
- .object({
- context: FilterSchema.shape.context,
- title: FilterSchema.shape.title,
- filter_action: FilterSchema.shape.filter_action,
- expires_in: z.coerce
- .number()
- .int()
- .min(60)
- .max(60 * 60 * 24 * 365 * 5)
- .openapi({
- description:
- "How many seconds from now should the filter expire?",
- }),
- keywords_attributes: z.array(
- FilterKeywordSchema.pick({
- keyword: true,
- whole_word: true,
- id: true,
- })
- .extend({
- // biome-ignore lint/style/useNamingConvention: _destroy is a Mastodon API imposed variable name
- _destroy: zBoolean
- .default(false)
- .openapi({
- description:
- "If true, will remove the keyword with the given ID.",
- }),
- })
- .partial(),
- ),
- })
- .partial(),
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Filter updated",
- content: {
- "application/json": {
- schema: FilterSchema,
- },
- },
- },
- 404: {
- description: "Filter not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
-const routeDelete = createRoute({
- method: "delete",
- path: "/api/v2/filters/{id}",
- summary: "Delete a filter",
- description: "Delete a filter group with the given id.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/filters/#delete",
- },
- tags: ["Filters"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnFilters],
- }),
- ] as const,
- request: {
- params: z.object({
- id: FilterSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Filter successfully deleted",
- },
- 404: {
- description: "Filter not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-export default apiRoute((app) => {
- app.openapi(routeGet, async (context) => {
- const { user } = context.get("auth");
- const { id } = context.req.valid("param");
-
- const userFilter = await db.query.Filters.findFirst({
- where: (filter, { eq, and }): SQL | undefined =>
- and(eq(filter.userId, user.id), eq(filter.id, id)),
- with: {
- keywords: true,
- },
- });
-
- if (!userFilter) {
- throw new ApiError(404, "Filter not found");
- }
-
- return context.json(
- {
- id: userFilter.id,
- title: userFilter.title,
- context: userFilter.context,
- expires_at: userFilter.expireAt
- ? new Date(userFilter.expireAt).toISOString()
- : null,
- filter_action: userFilter.filterAction,
- keywords: userFilter.keywords.map((keyword) => ({
- id: keyword.id,
- keyword: keyword.keyword,
- whole_word: keyword.wholeWord,
- })),
- statuses: [],
- },
- 200,
- );
- });
-
- app.openapi(routePut, async (context) => {
- const { user } = context.get("auth");
- const { id } = context.req.valid("param");
- const {
- title,
- context: ctx,
- filter_action,
- expires_in,
- keywords_attributes,
- } = context.req.valid("json");
-
- await db
- .update(Filters)
- .set({
- title,
- context: ctx ?? [],
- filterAction: filter_action,
- expireAt: new Date(
- Date.now() + (expires_in ?? 0),
- ).toISOString(),
- })
- .where(and(eq(Filters.userId, user.id), eq(Filters.id, id)));
-
- const toUpdate = keywords_attributes
- ?.filter((keyword) => keyword.id && !keyword._destroy)
- .map((keyword) => ({
- keyword: keyword.keyword,
- wholeWord: keyword.whole_word ?? false,
- id: keyword.id,
- }));
-
- const toDelete = keywords_attributes
- ?.filter((keyword) => keyword._destroy && keyword.id)
- .map((keyword) => keyword.id ?? "");
-
- if (toUpdate && toUpdate.length > 0) {
- for (const keyword of toUpdate) {
- await db
- .update(FilterKeywords)
- .set(keyword)
- .where(
- and(
- eq(FilterKeywords.filterId, id),
- eq(FilterKeywords.id, keyword.id ?? ""),
- ),
- );
- }
- }
-
- if (toDelete && toDelete.length > 0) {
- await db
- .delete(FilterKeywords)
- .where(
- and(
- eq(FilterKeywords.filterId, id),
- inArray(FilterKeywords.id, toDelete),
- ),
- );
- }
-
- const updatedFilter = await db.query.Filters.findFirst({
- where: (filter, { eq, and }): SQL | undefined =>
- and(eq(filter.userId, user.id), eq(filter.id, id)),
- with: {
- keywords: true,
- },
- });
-
- if (!updatedFilter) {
- throw new Error("Failed to update filter");
- }
-
- return context.json(
- {
- id: updatedFilter.id,
- title: updatedFilter.title,
- context: updatedFilter.context,
- expires_at: updatedFilter.expireAt
- ? new Date(updatedFilter.expireAt).toISOString()
- : null,
- filter_action: updatedFilter.filterAction,
- keywords: updatedFilter.keywords.map((keyword) => ({
- id: keyword.id,
- keyword: keyword.keyword,
- whole_word: keyword.wholeWord,
- })),
- statuses: [],
- },
- 200,
- );
- });
-
- app.openapi(routeDelete, async (context) => {
- const { user } = context.get("auth");
- const { id } = context.req.valid("param");
-
- await db
- .delete(Filters)
- .where(and(eq(Filters.userId, user.id), eq(Filters.id, id)));
-
- return context.body(null, 204);
- });
-});
diff --git a/api/api/v2/filters/:id/index.test.ts b/api/api/v2/filters/[id]/index.test.ts
similarity index 100%
rename from api/api/v2/filters/:id/index.test.ts
rename to api/api/v2/filters/[id]/index.test.ts
diff --git a/api/api/v2/filters/[id]/index.ts b/api/api/v2/filters/[id]/index.ts
new file mode 100644
index 00000000..dd6d08c9
--- /dev/null
+++ b/api/api/v2/filters/[id]/index.ts
@@ -0,0 +1,303 @@
+import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
+import {
+ FilterKeyword as FilterKeywordSchema,
+ Filter as FilterSchema,
+ zBoolean,
+} from "@versia/client/schemas";
+import { RolePermission } from "@versia/client/schemas";
+import { db } from "@versia/kit/db";
+import { FilterKeywords, Filters } from "@versia/kit/tables";
+import { type SQL, and, eq, inArray } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) => {
+ app.get(
+ "/api/v2/filters/:id",
+ describeRoute({
+ summary: "View a specific filter",
+ externalDocs: {
+ url: "Obtain a single filter group owned by the current user.",
+ },
+ tags: ["Filters"],
+ responses: {
+ 200: {
+ description: "Filter",
+ content: {
+ "application/json": {
+ schema: resolver(FilterSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Filter not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnFilters],
+ }),
+ validator(
+ "param",
+ z.object({ id: FilterSchema.shape.id }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { id } = context.req.valid("param");
+
+ const userFilter = await db.query.Filters.findFirst({
+ where: (filter, { eq, and }): SQL | undefined =>
+ and(eq(filter.userId, user.id), eq(filter.id, id)),
+ with: {
+ keywords: true,
+ },
+ });
+
+ if (!userFilter) {
+ throw new ApiError(404, "Filter not found");
+ }
+
+ return context.json(
+ {
+ id: userFilter.id,
+ title: userFilter.title,
+ context: userFilter.context,
+ expires_at: userFilter.expireAt
+ ? new Date(userFilter.expireAt).toISOString()
+ : null,
+ filter_action: userFilter.filterAction,
+ keywords: userFilter.keywords.map((keyword) => ({
+ id: keyword.id,
+ keyword: keyword.keyword,
+ whole_word: keyword.wholeWord,
+ })),
+ statuses: [],
+ },
+ 200,
+ );
+ },
+ );
+
+ app.put(
+ "/api/v2/filters/:id",
+ describeRoute({
+ summary: "Update a filter",
+ description: "Update a filter group with the given parameters.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/filters/#update",
+ },
+ tags: ["Filters"],
+ responses: {
+ 200: {
+ description: "Filter updated",
+ content: {
+ "application/json": {
+ schema: resolver(FilterSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Filter not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnFilters],
+ }),
+ jsonOrForm(),
+ validator(
+ "param",
+ z.object({ id: FilterSchema.shape.id }),
+ handleZodError,
+ ),
+ validator(
+ "json",
+ z
+ .object({
+ context: FilterSchema.shape.context,
+ title: FilterSchema.shape.title,
+ filter_action: FilterSchema.shape.filter_action,
+ expires_in: z.coerce
+ .number()
+ .int()
+ .min(60)
+ .max(60 * 60 * 24 * 365 * 5)
+ .openapi({
+ description:
+ "How many seconds from now should the filter expire?",
+ }),
+ keywords_attributes: z.array(
+ FilterKeywordSchema.pick({
+ keyword: true,
+ whole_word: true,
+ id: true,
+ })
+ .extend({
+ // biome-ignore lint/style/useNamingConvention: _destroy is a Mastodon API imposed variable name
+ _destroy: zBoolean.default(false).openapi({
+ description:
+ "If true, will remove the keyword with the given ID.",
+ }),
+ })
+ .partial(),
+ ),
+ })
+ .partial(),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { id } = context.req.valid("param");
+ const {
+ title,
+ context: ctx,
+ filter_action,
+ expires_in,
+ keywords_attributes,
+ } = context.req.valid("json");
+
+ await db
+ .update(Filters)
+ .set({
+ title,
+ context: ctx ?? [],
+ filterAction: filter_action,
+ expireAt: new Date(
+ Date.now() + (expires_in ?? 0),
+ ).toISOString(),
+ })
+ .where(and(eq(Filters.userId, user.id), eq(Filters.id, id)));
+
+ const toUpdate = keywords_attributes
+ ?.filter((keyword) => keyword.id && !keyword._destroy)
+ .map((keyword) => ({
+ keyword: keyword.keyword,
+ wholeWord: keyword.whole_word ?? false,
+ id: keyword.id,
+ }));
+
+ const toDelete = keywords_attributes
+ ?.filter((keyword) => keyword._destroy && keyword.id)
+ .map((keyword) => keyword.id ?? "");
+
+ if (toUpdate && toUpdate.length > 0) {
+ for (const keyword of toUpdate) {
+ await db
+ .update(FilterKeywords)
+ .set(keyword)
+ .where(
+ and(
+ eq(FilterKeywords.filterId, id),
+ eq(FilterKeywords.id, keyword.id ?? ""),
+ ),
+ );
+ }
+ }
+
+ if (toDelete && toDelete.length > 0) {
+ await db
+ .delete(FilterKeywords)
+ .where(
+ and(
+ eq(FilterKeywords.filterId, id),
+ inArray(FilterKeywords.id, toDelete),
+ ),
+ );
+ }
+
+ const updatedFilter = await db.query.Filters.findFirst({
+ where: (filter, { eq, and }): SQL | undefined =>
+ and(eq(filter.userId, user.id), eq(filter.id, id)),
+ with: {
+ keywords: true,
+ },
+ });
+
+ if (!updatedFilter) {
+ throw new Error("Failed to update filter");
+ }
+
+ return context.json(
+ {
+ id: updatedFilter.id,
+ title: updatedFilter.title,
+ context: updatedFilter.context,
+ expires_at: updatedFilter.expireAt
+ ? new Date(updatedFilter.expireAt).toISOString()
+ : null,
+ filter_action: updatedFilter.filterAction,
+ keywords: updatedFilter.keywords.map((keyword) => ({
+ id: keyword.id,
+ keyword: keyword.keyword,
+ whole_word: keyword.wholeWord,
+ })),
+ statuses: [],
+ },
+ 200,
+ );
+ },
+ );
+
+ app.delete(
+ "/api/v2/filters/:id",
+ describeRoute({
+ summary: "Delete a filter",
+ description: "Delete a filter group with the given id.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/filters/#delete",
+ },
+ tags: ["Filters"],
+ responses: {
+ 200: {
+ description: "Filter successfully deleted",
+ },
+ 404: {
+ description: "Filter not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnFilters],
+ }),
+ validator(
+ "param",
+ z.object({ id: FilterSchema.shape.id }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const { id } = context.req.valid("param");
+
+ await db
+ .delete(Filters)
+ .where(and(eq(Filters.userId, user.id), eq(Filters.id, id)));
+
+ return context.body(null, 204);
+ },
+ );
+});
diff --git a/api/api/v2/filters/index.ts b/api/api/v2/filters/index.ts
index fac83d84..c98f13ef 100644
--- a/api/api/v2/filters/index.ts
+++ b/api/api/v2/filters/index.ts
@@ -1,5 +1,4 @@
-import { apiRoute, auth, jsonOrForm } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import {
FilterKeyword as FilterKeywordSchema,
Filter as FilterSchema,
@@ -8,190 +7,185 @@ import { RolePermission } from "@versia/client/schemas";
import { db } from "@versia/kit/db";
import { FilterKeywords, Filters } from "@versia/kit/tables";
import type { SQL } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const routeGet = createRoute({
- method: "get",
- path: "/api/v2/filters",
- summary: "View all filters",
- description: "Obtain a list of all filter groups for the current user.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/filters/#get",
- },
- tags: ["Filters"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnFilters],
- }),
- jsonOrForm(),
- ] as const,
- responses: {
- 200: {
- description: "Filters",
- content: {
- "application/json": {
- schema: z.array(FilterSchema),
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- },
-});
-
-const routePost = createRoute({
- method: "post",
- path: "/api/v2/filters",
- summary: "Create a filter",
- description: "Create a filter group with the given parameters.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/filters/#create",
- },
- tags: ["Filters"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.ManageOwnFilters],
- }),
- jsonOrForm(),
- ] as const,
- request: {
- body: {
- content: {
- "application/json": {
- schema: z.object({
- context: FilterSchema.shape.context,
- title: FilterSchema.shape.title,
- filter_action: FilterSchema.shape.filter_action,
- expires_in: z.coerce
- .number()
- .int()
- .min(60)
- .max(60 * 60 * 24 * 365 * 5)
- .optional()
- .openapi({
- description:
- "How many seconds from now should the filter expire?",
- }),
- keywords_attributes: z
- .array(
- FilterKeywordSchema.pick({
- keyword: true,
- whole_word: true,
- }),
- )
- .optional(),
- }),
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Created filter",
- content: {
- "application/json": {
- schema: FilterSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
-
export default apiRoute((app) => {
- app.openapi(routeGet, async (context) => {
- const { user } = context.get("auth");
-
- const userFilters = await db.query.Filters.findMany({
- where: (filter, { eq }): SQL | undefined =>
- eq(filter.userId, user.id),
- with: {
- keywords: true,
+ app.get(
+ "/api/v2/filters",
+ describeRoute({
+ summary: "View all filters",
+ description:
+ "Obtain a list of all filter groups for the current user.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/filters/#get",
},
- });
-
- return context.json(
- userFilters.map((filter) => ({
- id: filter.id,
- title: filter.title,
- context: filter.context,
- expires_at: filter.expireAt
- ? new Date(Date.now() + filter.expireAt).toISOString()
- : null,
- filter_action: filter.filterAction,
- keywords: filter.keywords.map((keyword) => ({
- id: keyword.id,
- keyword: keyword.keyword,
- whole_word: keyword.wholeWord,
- })),
- statuses: [],
- })),
- 200,
- );
- });
-
- app.openapi(routePost, async (context) => {
- const { user } = context.get("auth");
- const {
- title,
- context: ctx,
- filter_action,
- expires_in,
- keywords_attributes,
- } = context.req.valid("json");
-
- const newFilter = (
- await db
- .insert(Filters)
- .values({
- title,
- context: ctx,
- filterAction: filter_action,
- expireAt: new Date(
- Date.now() + (expires_in ?? 0),
- ).toISOString(),
- userId: user.id,
- })
- .returning()
- )[0];
-
- if (!newFilter) {
- throw new Error("Failed to create filter");
- }
-
- const insertedKeywords =
- keywords_attributes && keywords_attributes.length > 0
- ? await db
- .insert(FilterKeywords)
- .values(
- keywords_attributes?.map((keyword) => ({
- filterId: newFilter.id,
- keyword: keyword.keyword,
- wholeWord: keyword.whole_word ?? false,
- })) ?? [],
- )
- .returning()
- : [];
-
- return context.json(
- {
- id: newFilter.id,
- title: newFilter.title,
- context: newFilter.context,
- expires_at: expires_in
- ? new Date(Date.now() + expires_in).toISOString()
- : null,
- filter_action: newFilter.filterAction,
- keywords: insertedKeywords.map((keyword) => ({
- id: keyword.id,
- keyword: keyword.keyword,
- whole_word: keyword.wholeWord,
- })),
- statuses: [],
+ tags: ["Filters"],
+ responses: {
+ 200: {
+ description: "Filters",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(FilterSchema)),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
},
- 200,
- );
- });
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnFilters],
+ }),
+ async (context) => {
+ const { user } = context.get("auth");
+
+ const userFilters = await db.query.Filters.findMany({
+ where: (filter, { eq }): SQL | undefined =>
+ eq(filter.userId, user.id),
+ with: {
+ keywords: true,
+ },
+ });
+
+ return context.json(
+ userFilters.map((filter) => ({
+ id: filter.id,
+ title: filter.title,
+ context: filter.context,
+ expires_at: filter.expireAt
+ ? new Date(Date.now() + filter.expireAt).toISOString()
+ : null,
+ filter_action: filter.filterAction,
+ keywords: filter.keywords.map((keyword) => ({
+ id: keyword.id,
+ keyword: keyword.keyword,
+ whole_word: keyword.wholeWord,
+ })),
+ statuses: [],
+ })),
+ 200,
+ );
+ },
+ );
+
+ app.post(
+ "/api/v2/filters",
+ describeRoute({
+ summary: "Create a filter",
+ description: "Create a filter group with the given parameters.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/filters/#create",
+ },
+ tags: ["Filters"],
+ responses: {
+ 200: {
+ description: "Created filter",
+ content: {
+ "application/json": {
+ schema: resolver(FilterSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.ManageOwnFilters],
+ }),
+ jsonOrForm(),
+ validator(
+ "json",
+ z.object({
+ context: FilterSchema.shape.context,
+ title: FilterSchema.shape.title,
+ filter_action: FilterSchema.shape.filter_action,
+ expires_in: z.coerce
+ .number()
+ .int()
+ .min(60)
+ .max(60 * 60 * 24 * 365 * 5)
+ .optional()
+ .openapi({
+ description:
+ "How many seconds from now should the filter expire?",
+ }),
+ keywords_attributes: z
+ .array(
+ FilterKeywordSchema.pick({
+ keyword: true,
+ whole_word: true,
+ }),
+ )
+ .optional(),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { user } = context.get("auth");
+ const {
+ title,
+ context: ctx,
+ filter_action,
+ expires_in,
+ keywords_attributes,
+ } = context.req.valid("json");
+
+ const newFilter = (
+ await db
+ .insert(Filters)
+ .values({
+ title,
+ context: ctx,
+ filterAction: filter_action,
+ expireAt: new Date(
+ Date.now() + (expires_in ?? 0),
+ ).toISOString(),
+ userId: user.id,
+ })
+ .returning()
+ )[0];
+
+ if (!newFilter) {
+ throw new Error("Failed to create filter");
+ }
+
+ const insertedKeywords =
+ keywords_attributes && keywords_attributes.length > 0
+ ? await db
+ .insert(FilterKeywords)
+ .values(
+ keywords_attributes?.map((keyword) => ({
+ filterId: newFilter.id,
+ keyword: keyword.keyword,
+ wholeWord: keyword.whole_word ?? false,
+ })) ?? [],
+ )
+ .returning()
+ : [];
+
+ return context.json(
+ {
+ id: newFilter.id,
+ title: newFilter.title,
+ context: newFilter.context,
+ expires_at: expires_in
+ ? new Date(Date.now() + expires_in).toISOString()
+ : null,
+ filter_action: newFilter.filterAction,
+ keywords: insertedKeywords.map((keyword) => ({
+ id: keyword.id,
+ keyword: keyword.keyword,
+ whole_word: keyword.wholeWord,
+ })),
+ statuses: [],
+ },
+ 200,
+ );
+ },
+ );
});
diff --git a/api/api/v2/instance/index.ts b/api/api/v2/instance/index.ts
index 893e6f58..daa94c6c 100644
--- a/api/api/v2/instance/index.ts
+++ b/api/api/v2/instance/index.ts
@@ -1,177 +1,182 @@
import { apiRoute } from "@/api";
import { proxyUrl } from "@/response";
-import { createRoute } from "@hono/zod-openapi";
import { Instance as InstanceSchema } from "@versia/client/schemas";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { config } from "~/config.ts";
import pkg from "~/package.json";
-const route = createRoute({
- method: "get",
- path: "/api/v2/instance",
- summary: "View server information",
- description: "Obtain general information about the server.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/instance/#v2",
- },
- tags: ["Instance"],
- responses: {
- 200: {
- description: "Server information",
- content: {
- "application/json": {
- schema: InstanceSchema,
- },
- },
- },
- },
-});
-
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- // Get first admin, or first user if no admin exists
- const contactAccount =
- (await User.fromSql(
- and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
- )) ?? (await User.fromSql(isNull(Users.instanceId)));
-
- const monthlyActiveUsers = await User.getActiveInPeriod(
- 30 * 24 * 60 * 60 * 1000,
- );
-
- const oidcConfig = config.plugins?.config?.["@versia/openid"] as
- | {
- forced?: boolean;
- providers?: {
- id: string;
- name: string;
- icon: string;
- }[];
- }
- | undefined;
-
- // TODO: fill in more values
- return context.json({
- domain: config.http.base_url.hostname,
- title: config.instance.name,
- version: "4.3.0-alpha.3+glitch",
- versia_version: pkg.version,
- source_url: pkg.repository.url,
- description: config.instance.description,
- usage: {
- users: {
- active_month: monthlyActiveUsers,
- },
+ app.get(
+ "/api/v2/instance",
+ describeRoute({
+ summary: "View server information",
+ description: "Obtain general information about the server.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/instance/#v2",
},
- api_versions: {
- mastodon: 1,
- },
- thumbnail: {
- url: config.instance.branding.logo
- ? proxyUrl(config.instance.branding.logo).toString()
- : pkg.icon,
- },
- banner: {
- url: config.instance.branding.banner
- ? proxyUrl(config.instance.branding.banner).toString()
- : null,
- },
- icon: [],
- languages: config.instance.languages,
- configuration: {
- urls: {
- // TODO: Implement Streaming API
- streaming: "",
- },
- vapid: {
- public_key:
- config.notifications.push?.vapid_keys.public ?? "",
- },
- accounts: {
- max_featured_tags: 100,
- max_displayname_characters:
- config.validation.accounts.max_displayname_characters,
- avatar_limit: config.validation.accounts.max_avatar_bytes,
- header_limit: config.validation.accounts.max_header_bytes,
- max_username_characters:
- config.validation.accounts.max_username_characters,
- max_note_characters:
- config.validation.accounts.max_bio_characters,
- max_pinned_statuses:
- config.validation.accounts.max_pinned_notes,
- fields: {
- max_fields: config.validation.accounts.max_field_count,
- max_name_characters:
- config.validation.accounts
- .max_field_name_characters,
- max_value_characters:
- config.validation.accounts
- .max_field_value_characters,
+ tags: ["Instance"],
+ responses: {
+ 200: {
+ description: "Server information",
+ content: {
+ "application/json": {
+ schema: resolver(InstanceSchema),
+ },
},
},
- statuses: {
- max_characters: config.validation.notes.max_characters,
- max_media_attachments:
- config.validation.notes.max_attachments,
- // TODO: Implement
- characters_reserved_per_url: 13,
- },
- media_attachments: {
- supported_mime_types:
- config.validation.media.allowed_mime_types,
- image_size_limit: config.validation.media.max_bytes,
- image_matrix_limit: 1 ** 10,
- video_size_limit: 1 ** 10,
- video_frame_rate_limit: 60,
- video_matrix_limit: 1 ** 10,
- description_limit:
- config.validation.media.max_description_characters,
- },
- emojis: {
- emoji_size_limit: config.validation.emojis.max_bytes,
- max_shortcode_characters:
- config.validation.emojis.max_shortcode_characters,
- max_description_characters:
- config.validation.emojis.max_description_characters,
- },
- polls: {
- max_characters_per_option:
- config.validation.polls.max_option_characters,
- max_expiration:
- config.validation.polls.max_duration_seconds,
- max_options: config.validation.polls.max_options,
- min_expiration:
- config.validation.polls.min_duration_seconds,
- },
- translation: {
- enabled: false,
- },
},
- registrations: {
- enabled: config.registration.allow,
- approval_required: config.registration.require_approval,
- message: config.registration.message ?? null,
- },
- contact: {
- email: config.instance.contact.email,
- account: (contactAccount as User)?.toApi(),
- },
- rules: config.instance.rules.map((r, index) => ({
- id: String(index),
- text: r.text,
- hint: r.hint,
- })),
- sso: {
- forced: oidcConfig?.forced ?? false,
- providers:
- oidcConfig?.providers?.map((p) => ({
- name: p.name,
- icon: p.icon ? proxyUrl(new URL(p.icon)) : "",
- id: p.id,
- })) ?? [],
- },
- });
- }),
+ }),
+ async (context) => {
+ // Get first admin, or first user if no admin exists
+ const contactAccount =
+ (await User.fromSql(
+ and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
+ )) ?? (await User.fromSql(isNull(Users.instanceId)));
+
+ const monthlyActiveUsers = await User.getActiveInPeriod(
+ 30 * 24 * 60 * 60 * 1000,
+ );
+
+ const oidcConfig = config.plugins?.config?.["@versia/openid"] as
+ | {
+ forced?: boolean;
+ providers?: {
+ id: string;
+ name: string;
+ icon: string;
+ }[];
+ }
+ | undefined;
+
+ // TODO: fill in more values
+ return context.json({
+ domain: config.http.base_url.hostname,
+ title: config.instance.name,
+ version: "4.3.0-alpha.3+glitch",
+ versia_version: pkg.version,
+ source_url: pkg.repository.url,
+ description: config.instance.description,
+ usage: {
+ users: {
+ active_month: monthlyActiveUsers,
+ },
+ },
+ api_versions: {
+ mastodon: 1,
+ },
+ thumbnail: {
+ url: config.instance.branding.logo
+ ? proxyUrl(config.instance.branding.logo).toString()
+ : pkg.icon,
+ },
+ banner: {
+ url: config.instance.branding.banner
+ ? proxyUrl(config.instance.branding.banner).toString()
+ : null,
+ },
+ icon: [],
+ languages: config.instance.languages,
+ configuration: {
+ urls: {
+ // TODO: Implement Streaming API
+ streaming: "",
+ },
+ vapid: {
+ public_key:
+ config.notifications.push?.vapid_keys.public ?? "",
+ },
+ accounts: {
+ max_featured_tags: 100,
+ max_displayname_characters:
+ config.validation.accounts
+ .max_displayname_characters,
+ avatar_limit:
+ config.validation.accounts.max_avatar_bytes,
+ header_limit:
+ config.validation.accounts.max_header_bytes,
+ max_username_characters:
+ config.validation.accounts.max_username_characters,
+ max_note_characters:
+ config.validation.accounts.max_bio_characters,
+ max_pinned_statuses:
+ config.validation.accounts.max_pinned_notes,
+ fields: {
+ max_fields:
+ config.validation.accounts.max_field_count,
+ max_name_characters:
+ config.validation.accounts
+ .max_field_name_characters,
+ max_value_characters:
+ config.validation.accounts
+ .max_field_value_characters,
+ },
+ },
+ statuses: {
+ max_characters: config.validation.notes.max_characters,
+ max_media_attachments:
+ config.validation.notes.max_attachments,
+ // TODO: Implement
+ characters_reserved_per_url: 13,
+ },
+ media_attachments: {
+ supported_mime_types:
+ config.validation.media.allowed_mime_types,
+ image_size_limit: config.validation.media.max_bytes,
+ image_matrix_limit: 1 ** 10,
+ video_size_limit: 1 ** 10,
+ video_frame_rate_limit: 60,
+ video_matrix_limit: 1 ** 10,
+ description_limit:
+ config.validation.media.max_description_characters,
+ },
+ emojis: {
+ emoji_size_limit: config.validation.emojis.max_bytes,
+ max_shortcode_characters:
+ config.validation.emojis.max_shortcode_characters,
+ max_description_characters:
+ config.validation.emojis.max_description_characters,
+ },
+ polls: {
+ max_characters_per_option:
+ config.validation.polls.max_option_characters,
+ max_expiration:
+ config.validation.polls.max_duration_seconds,
+ max_options: config.validation.polls.max_options,
+ min_expiration:
+ config.validation.polls.min_duration_seconds,
+ },
+ translation: {
+ enabled: false,
+ },
+ },
+ registrations: {
+ enabled: config.registration.allow,
+ approval_required: config.registration.require_approval,
+ message: config.registration.message ?? null,
+ },
+ contact: {
+ email: config.instance.contact.email,
+ account: (contactAccount as User)?.toApi(),
+ },
+ rules: config.instance.rules.map((r, index) => ({
+ id: String(index),
+ text: r.text,
+ hint: r.hint,
+ })),
+ sso: {
+ forced: oidcConfig?.forced ?? false,
+ providers:
+ oidcConfig?.providers?.map((p) => ({
+ name: p.name,
+ icon: p.icon ? proxyUrl(new URL(p.icon)) : "",
+ id: p.id,
+ })) ?? [],
+ },
+ });
+ },
+ ),
);
diff --git a/api/api/v2/media/index.ts b/api/api/v2/media/index.ts
index 57ec4bac..b8a7ef3e 100644
--- a/api/api/v2/media/index.ts
+++ b/api/api/v2/media/index.ts
@@ -1,107 +1,102 @@
-import { apiRoute, auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, auth, handleZodError } from "@/api";
import { Attachment as AttachmentSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
-const route = createRoute({
- method: "post",
- path: "/api/v2/media",
- summary: "Upload media as an attachment (async)",
- description:
- "Creates a media attachment to be used with a new status. The full sized media will be processed asynchronously in the background for large uploads.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/media/#v2",
- },
- tags: ["Media"],
- middleware: [
+export default apiRoute((app) =>
+ app.post(
+ "/api/v2/media",
+ describeRoute({
+ summary: "Upload media as an attachment (async)",
+ description:
+ "Creates a media attachment to be used with a new status. The full sized media will be processed asynchronously in the background for large uploads.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/media/#v2",
+ },
+ tags: ["Media"],
+ responses: {
+ 200: {
+ description:
+ "MediaAttachment was created successfully, and the full-size file was processed synchronously.",
+ content: {
+ "application/json": {
+ schema: resolver(AttachmentSchema),
+ },
+ },
+ },
+ 202: {
+ description:
+ "MediaAttachment was created successfully, but the full-size file is still processing. Note that the MediaAttachment’s url will still be null, as the media is still being processed in the background. However, the preview_url should be available. Use GET /api/v1/media/:id to check the status of the media attachment.",
+ content: {
+ "application/json": {
+ // FIXME: Can't .extend the type to have a null url because it crashes zod-to-openapi
+ schema: resolver(AttachmentSchema),
+ },
+ },
+ },
+ 413: {
+ description: "Payload too large",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 415: {
+ description: "Unsupported media type",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: ApiError.missingAuthentication().schema,
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
auth({
auth: true,
scopes: ["write:media"],
permissions: [RolePermission.ManageOwnMedia],
}),
- ] as const,
- request: {
- body: {
- content: {
- "multipart/form-data": {
- schema: z.object({
- file: z.instanceof(File).openapi({
- description:
- "The file to be attached, encoded using multipart form data. The file must have a MIME type.",
- }),
- thumbnail: z.instanceof(File).optional().openapi({
- description:
- "The custom thumbnail of the media to be attached, encoded using multipart form data.",
- }),
+ validator(
+ "form",
+ z.object({
+ file: z.instanceof(File).openapi({
+ description:
+ "The file to be attached, encoded using multipart form data. The file must have a MIME type.",
+ }),
+ thumbnail: z.instanceof(File).optional().openapi({
+ description:
+ "The custom thumbnail of the media to be attached, encoded using multipart form data.",
+ }),
+ description: AttachmentSchema.shape.description.optional(),
+ focus: z
+ .string()
+ .optional()
+ .openapi({
description:
- AttachmentSchema.shape.description.optional(),
- focus: z
- .string()
- .optional()
- .openapi({
- description:
- "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
- externalDocs: {
- url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
- },
- }),
+ "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
+ },
}),
- },
- },
- },
- },
- responses: {
- 200: {
- description:
- "MediaAttachment was created successfully, and the full-size file was processed synchronously.",
- content: {
- "application/json": {
- schema: AttachmentSchema,
- },
- },
- },
- 202: {
- description:
- "MediaAttachment was created successfully, but the full-size file is still processing. Note that the MediaAttachment’s url will still be null, as the media is still being processed in the background. However, the preview_url should be available. Use GET /api/v1/media/:id to check the status of the media attachment.",
- content: {
- "application/json": {
- // FIXME: Can't .extend the type to have a null url because it crashes zod-to-openapi
- schema: AttachmentSchema,
- },
- },
- },
- 413: {
- description: "Payload too large",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 415: {
- description: "Unsupported media type",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: ApiError.missingAuthentication().schema,
- 422: ApiError.validationFailed().schema,
- },
-});
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { file, thumbnail, description } = context.req.valid("form");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { file, thumbnail, description } = context.req.valid("form");
+ const attachment = await Media.fromFile(file, {
+ thumbnail,
+ description: description ?? undefined,
+ });
- const attachment = await Media.fromFile(file, {
- thumbnail,
- description: description ?? undefined,
- });
-
- return context.json(attachment.toApi(), 200);
- }),
+ return context.json(attachment.toApi(), 200);
+ },
+ ),
);
diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts
index 0e7455cf..eab6a163 100644
--- a/api/api/v2/search/index.ts
+++ b/api/api/v2/search/index.ts
@@ -1,5 +1,10 @@
-import { apiRoute, auth, parseUserAddress, userAddressValidator } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import {
+ apiRoute,
+ auth,
+ handleZodError,
+ parseUserAddress,
+ userAddressValidator,
+} from "@/api";
import {
Account as AccountSchema,
Id,
@@ -10,19 +15,112 @@ import { RolePermission } from "@versia/client/schemas";
import { Note, User, db } from "@versia/kit/db";
import { Instances, Notes, Users } from "@versia/kit/tables";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { searchManager } from "~/classes/search/search-manager";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "get",
- path: "/api/v2/search",
- summary: "Perform a search",
- externalDocs: {
- url: "https://docs.joinmastodon.org/methods/search/#v2",
- },
- tags: ["Search"],
- middleware: [
+export default apiRoute((app) =>
+ app.get(
+ "/api/v2/search",
+ describeRoute({
+ summary: "Perform a search",
+ externalDocs: {
+ url: "https://docs.joinmastodon.org/methods/search/#v2",
+ },
+ tags: ["Search"],
+ responses: {
+ 200: {
+ description: "Search results",
+ content: {
+ "application/json": {
+ schema: resolver(SearchSchema),
+ },
+ },
+ },
+ 401: {
+ description:
+ "Cannot use resolve or offset without being authenticated",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 501: {
+ description: "Search is not enabled on this server",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 422: ApiError.validationFailed().schema,
+ },
+ }),
+ validator(
+ "query",
+ z.object({
+ q: z.string().trim().openapi({
+ description: "The search query.",
+ example: "versia",
+ }),
+ type: z
+ .enum(["accounts", "hashtags", "statuses"])
+ .optional()
+ .openapi({
+ description:
+ "Specify whether to search for only accounts, hashtags, statuses",
+ example: "accounts",
+ }),
+ resolve: zBoolean.default(false).openapi({
+ description:
+ "Only relevant if type includes accounts. If true and (a) the search query is for a remote account (e.g., someaccount@someother.server) and (b) the local server does not know about the account, WebFinger is used to try and resolve the account at someother.server. This provides the best recall at higher latency. If false only accounts the server knows about are returned.",
+ }),
+ following: zBoolean.default(false).openapi({
+ description:
+ "Only include accounts that the user is following?",
+ }),
+ account_id: AccountSchema.shape.id.optional().openapi({
+ description:
+ " If provided, will only return statuses authored by this account.",
+ }),
+ exclude_unreviewed: zBoolean.default(false).openapi({
+ description:
+ "Filter out unreviewed tags? Use true when trying to find trending tags.",
+ }),
+ max_id: Id.optional().openapi({
+ description:
+ "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
+ example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
+ }),
+ since_id: Id.optional().openapi({
+ description:
+ "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
+ example: undefined,
+ }),
+ min_id: Id.optional().openapi({
+ description:
+ "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
+ example: undefined,
+ }),
+ limit: z.coerce
+ .number()
+ .int()
+ .min(1)
+ .max(40)
+ .default(20)
+ .openapi({
+ description: "Maximum number of results to return.",
+ }),
+ offset: z.coerce.number().int().min(0).default(0).openapi({
+ description: "Skip the first n results.",
+ }),
+ }),
+ handleZodError,
+ ),
auth({
auth: false,
scopes: ["read:search"],
@@ -32,246 +130,171 @@ const route = createRoute({
RolePermission.ViewNotes,
],
}),
- ] as const,
- request: {
- query: z.object({
- q: z.string().trim().openapi({
- description: "The search query.",
- example: "versia",
- }),
- type: z
- .enum(["accounts", "hashtags", "statuses"])
- .optional()
- .openapi({
- description:
- "Specify whether to search for only accounts, hashtags, statuses",
- example: "accounts",
- }),
- resolve: zBoolean.default(false).openapi({
- description:
- "Only relevant if type includes accounts. If true and (a) the search query is for a remote account (e.g., someaccount@someother.server) and (b) the local server does not know about the account, WebFinger is used to try and resolve the account at someother.server. This provides the best recall at higher latency. If false only accounts the server knows about are returned.",
- }),
- following: zBoolean.default(false).openapi({
- description:
- "Only include accounts that the user is following?",
- }),
- account_id: AccountSchema.shape.id.optional().openapi({
- description:
- " If provided, will only return statuses authored by this account.",
- }),
- exclude_unreviewed: zBoolean.default(false).openapi({
- description:
- "Filter out unreviewed tags? Use true when trying to find trending tags.",
- }),
- max_id: Id.optional().openapi({
- description:
- "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
- example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
- }),
- since_id: Id.optional().openapi({
- description:
- "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
- example: undefined,
- }),
- min_id: Id.optional().openapi({
- description:
- "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
- example: undefined,
- }),
- limit: z.coerce.number().int().min(1).max(40).default(20).openapi({
- description: "Maximum number of results to return.",
- }),
- offset: z.coerce.number().int().min(0).default(0).openapi({
- description: "Skip the first n results.",
- }),
- }),
- },
- responses: {
- 200: {
- description: "Search results",
- content: {
- "application/json": {
- schema: SearchSchema,
- },
- },
- },
- 401: {
- description:
- "Cannot use resolve or offset without being authenticated",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 501: {
- description: "Search is not enabled on this server",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 422: ApiError.validationFailed().schema,
- },
-});
+ async (context) => {
+ const { user } = context.get("auth");
+ const { q, type, resolve, following, account_id, limit, offset } =
+ context.req.valid("query");
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { user } = context.get("auth");
- const { q, type, resolve, following, account_id, limit, offset } =
- context.req.valid("query");
-
- if (!user && (resolve || offset)) {
- throw new ApiError(
- 401,
- "Usage of 'resolve' or 'offset' requires authentication",
- );
- }
-
- if (!config.search.enabled) {
- throw new ApiError(501, "Search is not enabled on this server");
- }
-
- let accountResults: string[] = [];
- let statusResults: string[] = [];
-
- if (!type || type === "accounts") {
- // Check if q is matching format username@domain.com or @username@domain.com
- const accountMatches = q?.trim().match(userAddressValidator);
- if (accountMatches) {
- // Remove leading @ if it exists
- if (accountMatches[0].startsWith("@")) {
- accountMatches[0] = accountMatches[0].slice(1);
- }
-
- const { username, domain } = parseUserAddress(
- accountMatches[0],
+ if (!user && (resolve || offset)) {
+ throw new ApiError(
+ 401,
+ "Usage of 'resolve' or 'offset' requires authentication",
);
+ }
- const accountId = (
- await db
- .select({
- id: Users.id,
- })
- .from(Users)
- .leftJoin(Instances, eq(Users.instanceId, Instances.id))
- .where(
- and(
- eq(Users.username, username),
- domain
- ? eq(Instances.baseUrl, domain)
- : isNull(Users.instanceId),
- ),
- )
- )[0]?.id;
+ if (!config.search.enabled) {
+ throw new ApiError(501, "Search is not enabled on this server");
+ }
- const account = accountId ? await User.fromId(accountId) : null;
+ let accountResults: string[] = [];
+ let statusResults: string[] = [];
- if (account) {
- return context.json(
- {
- accounts: [account.toApi()],
- statuses: [],
- hashtags: [],
- },
- 200,
+ if (!type || type === "accounts") {
+ // Check if q is matching format username@domain.com or @username@domain.com
+ const accountMatches = q?.trim().match(userAddressValidator);
+ if (accountMatches) {
+ // Remove leading @ if it exists
+ if (accountMatches[0].startsWith("@")) {
+ accountMatches[0] = accountMatches[0].slice(1);
+ }
+
+ const { username, domain } = parseUserAddress(
+ accountMatches[0],
);
- }
- if (resolve && domain) {
- const manager = await (
- user ?? User
- ).getFederationRequester();
+ const accountId = (
+ await db
+ .select({
+ id: Users.id,
+ })
+ .from(Users)
+ .leftJoin(
+ Instances,
+ eq(Users.instanceId, Instances.id),
+ )
+ .where(
+ and(
+ eq(Users.username, username),
+ domain
+ ? eq(Instances.baseUrl, domain)
+ : isNull(Users.instanceId),
+ ),
+ )
+ )[0]?.id;
- const uri = await User.webFinger(manager, username, domain);
+ const account = accountId
+ ? await User.fromId(accountId)
+ : null;
- if (uri) {
- const newUser = await User.resolve(uri);
+ if (account) {
+ return context.json(
+ {
+ accounts: [account.toApi()],
+ statuses: [],
+ hashtags: [],
+ },
+ 200,
+ );
+ }
- if (newUser) {
- return context.json(
- {
- accounts: [newUser.toApi()],
- statuses: [],
- hashtags: [],
- },
- 200,
- );
+ if (resolve && domain) {
+ const manager = await (
+ user ?? User
+ ).getFederationRequester();
+
+ const uri = await User.webFinger(
+ manager,
+ username,
+ domain,
+ );
+
+ if (uri) {
+ const newUser = await User.resolve(uri);
+
+ if (newUser) {
+ return context.json(
+ {
+ accounts: [newUser.toApi()],
+ statuses: [],
+ hashtags: [],
+ },
+ 200,
+ );
+ }
}
}
}
+
+ accountResults = await searchManager.searchAccounts(
+ q,
+ limit,
+ offset,
+ );
}
- accountResults = await searchManager.searchAccounts(
- q,
- limit,
- offset,
- );
- }
+ if (!type || type === "statuses") {
+ statusResults = await searchManager.searchStatuses(
+ q,
+ limit,
+ offset,
+ );
+ }
- if (!type || type === "statuses") {
- statusResults = await searchManager.searchStatuses(
- q,
- limit,
- offset,
- );
- }
-
- const accounts =
- accountResults.length > 0
- ? await User.manyFromSql(
- and(
- inArray(
- Users.id,
- accountResults.map((hit) => hit),
+ const accounts =
+ accountResults.length > 0
+ ? await User.manyFromSql(
+ and(
+ inArray(
+ Users.id,
+ accountResults.map((hit) => hit),
+ ),
+ user && following
+ ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${
+ user?.id
+ } AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${
+ Users.id
+ })`
+ : undefined,
),
- user && following
- ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${
- user?.id
- } AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${
- Users.id
- })`
- : undefined,
- ),
- )
- : [];
+ )
+ : [];
- const statuses =
- statusResults.length > 0
- ? await Note.manyFromSql(
- and(
- inArray(
- Notes.id,
- statusResults.map((hit) => hit),
+ const statuses =
+ statusResults.length > 0
+ ? await Note.manyFromSql(
+ and(
+ inArray(
+ Notes.id,
+ statusResults.map((hit) => hit),
+ ),
+ account_id
+ ? eq(Notes.authorId, account_id)
+ : undefined,
+ user && following
+ ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${
+ user?.id
+ } AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${
+ Notes.authorId
+ })`
+ : undefined,
),
- account_id
- ? eq(Notes.authorId, account_id)
- : undefined,
- user && following
- ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${
- user?.id
- } AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${
- Notes.authorId
- })`
- : undefined,
- ),
- undefined,
- undefined,
- undefined,
- user?.id,
- )
- : [];
+ undefined,
+ undefined,
+ undefined,
+ user?.id,
+ )
+ : [];
- return context.json(
- {
- accounts: accounts.map((account) => account.toApi()),
- statuses: await Promise.all(
- statuses.map((status) => status.toApi(user)),
- ),
- hashtags: [],
- },
- 200,
- );
- }),
+ return context.json(
+ {
+ accounts: accounts.map((account) => account.toApi()),
+ statuses: await Promise.all(
+ statuses.map((status) => status.toApi(user)),
+ ),
+ hashtags: [],
+ },
+ 200,
+ );
+ },
+ ),
);
diff --git a/api/inbox/index.ts b/api/inbox/index.ts
index 90760a6c..4d2c6663 100644
--- a/api/inbox/index.ts
+++ b/api/inbox/index.ts
@@ -1,62 +1,55 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { apiRoute, handleZodError } from "@/api";
import type { Entity } from "@versia/federation/types";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
+import { z } from "zod";
import { InboxJobType, inboxQueue } from "~/classes/queues/inbox";
-const schemas = {
- header: z.object({
- "versia-signature": z.string().optional(),
- "versia-signed-at": z.coerce.number().optional(),
- "versia-signed-by": z
- .string()
- .url()
- .or(z.string().startsWith("instance "))
- .optional(),
- authorization: z.string().optional(),
- }),
- body: z.any(),
-};
-
-const route = createRoute({
- method: "post",
- path: "/inbox",
- summary: "Instance federation inbox",
- tags: ["Federation"],
- request: {
- headers: schemas.header,
- body: {
- content: {
- "application/json": {
- schema: schemas.body,
+export default apiRoute((app) =>
+ app.post(
+ "/inbox",
+ describeRoute({
+ summary: "Instance federation inbox",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Request processing initiated",
},
},
+ }),
+ validator("json", z.any(), handleZodError),
+ validator(
+ "header",
+ z.object({
+ "versia-signature": z.string().optional(),
+ "versia-signed-at": z.coerce.number().optional(),
+ "versia-signed-by": z
+ .string()
+ .url()
+ .or(z.string().startsWith("instance "))
+ .optional(),
+ authorization: z.string().optional(),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const body: Entity = await context.req.valid("json");
+
+ await inboxQueue.add(InboxJobType.ProcessEntity, {
+ data: body,
+ headers: context.req.valid("header"),
+ request: {
+ body: await context.req.text(),
+ method: context.req.method,
+ url: context.req.url,
+ },
+ ip: context.env.ip ?? null,
+ });
+
+ return context.body(
+ "Request processing initiated.\nImplement the Instance Messaging Extension to receive any eventual feedback (errors, etc.)",
+ 200,
+ );
},
- },
- responses: {
- 200: {
- description: "Request processing initiated",
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const body: Entity = await context.req.valid("json");
-
- await inboxQueue.add(InboxJobType.ProcessEntity, {
- data: body,
- headers: context.req.valid("header"),
- request: {
- body: await context.req.text(),
- method: context.req.method,
- url: context.req.url,
- },
- ip: context.env.ip ?? null,
- });
-
- return context.body(
- "Request processing initiated.\nImplement the Instance Messaging Extension to receive any eventual feedback (errors, etc.)",
- 200,
- );
- }),
+ ),
);
diff --git a/api/likes/:uuid/index.ts b/api/likes/:uuid/index.ts
deleted file mode 100644
index 3558002d..00000000
--- a/api/likes/:uuid/index.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { LikeExtension as LikeSchema } from "@versia/federation/schemas";
-import { Like, User } from "@versia/kit/db";
-import { Likes } from "@versia/kit/tables";
-import { and, eq, sql } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-import { config } from "~/config.ts";
-
-const route = createRoute({
- method: "get",
- path: "/likes/{id}",
- summary: "Retrieve the Versia representation of a like.",
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- tags: ["Federation"],
- responses: {
- 200: {
- description: "Like",
- content: {
- "application/json": {
- schema: LikeSchema,
- },
- },
- },
- 404: {
- description:
- "Entity not found, is remote, or the requester is not allowed to view it.",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { id } = context.req.valid("param");
-
- // Don't fetch a like of a note that is not public or unlisted
- // prevents leaking the existence of a private note
- const like = await Like.fromSql(
- and(
- eq(Likes.id, id),
- sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${Likes.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`,
- ),
- );
-
- if (!like) {
- throw ApiError.likeNotFound();
- }
-
- const liker = await User.fromId(like.data.likerId);
-
- if (!liker || liker.isRemote()) {
- throw ApiError.accountNotFound();
- }
-
- // If base_url uses https and request uses http, rewrite request to use https
- // This fixes reverse proxy errors
- const reqUrl = new URL(context.req.url);
- if (
- config.http.base_url.protocol === "https:" &&
- reqUrl.protocol === "http:"
- ) {
- reqUrl.protocol = "https:";
- }
-
- const { headers } = await liker.sign(like.toVersia(), reqUrl, "GET");
-
- return context.json(like.toVersia(), 200, headers.toJSON());
- }),
-);
diff --git a/api/likes/[uuid]/index.ts b/api/likes/[uuid]/index.ts
new file mode 100644
index 00000000..d64136b4
--- /dev/null
+++ b/api/likes/[uuid]/index.ts
@@ -0,0 +1,85 @@
+import { apiRoute, handleZodError } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { LikeExtension as LikeSchema } from "@versia/federation/schemas";
+import { Like, User } from "@versia/kit/db";
+import { Likes } from "@versia/kit/tables";
+import { and, eq, sql } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+import { config } from "~/config.ts";
+
+export default apiRoute((app) =>
+ app.get(
+ "/likes/:id",
+ describeRoute({
+ summary: "Retrieve the Versia representation of a like.",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Like",
+ content: {
+ "application/json": {
+ schema: resolver(LikeSchema),
+ },
+ },
+ },
+ 404: {
+ description:
+ "Entity not found, is remote, or the requester is not allowed to view it.",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "param",
+ z.object({ id: StatusSchema.shape.id }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+
+ // Don't fetch a like of a note that is not public or unlisted
+ // prevents leaking the existence of a private note
+ const like = await Like.fromSql(
+ and(
+ eq(Likes.id, id),
+ sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${Likes.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`,
+ ),
+ );
+
+ if (!like) {
+ throw ApiError.likeNotFound();
+ }
+
+ const liker = await User.fromId(like.data.likerId);
+
+ if (!liker || liker.isRemote()) {
+ throw ApiError.accountNotFound();
+ }
+
+ // If base_url uses https and request uses http, rewrite request to use https
+ // This fixes reverse proxy errors
+ const reqUrl = new URL(context.req.url);
+ if (
+ config.http.base_url.protocol === "https:" &&
+ reqUrl.protocol === "http:"
+ ) {
+ reqUrl.protocol = "https:";
+ }
+
+ const { headers } = await liker.sign(
+ like.toVersia(),
+ reqUrl,
+ "GET",
+ );
+
+ return context.json(like.toVersia(), 200, headers.toJSON());
+ },
+ ),
+);
diff --git a/api/media/:hash/:name/index.ts b/api/media/:hash/:name/index.ts
deleted file mode 100644
index 85e725a3..00000000
--- a/api/media/:hash/:name/index.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { ApiError } from "~/classes/errors/api-error";
-
-const schemas = {
- param: z.object({
- hash: z.string(),
- name: z.string(),
- }),
- header: z.object({
- range: z.string().optional().default(""),
- }),
-};
-
-const route = createRoute({
- method: "get",
- path: "/media/{hash}/{name}",
- summary: "Get media file by hash and name",
- request: {
- params: schemas.param,
- headers: schemas.header,
- },
- responses: {
- 200: {
- description: "Media",
- content: {
- "*": {
- schema: z.any(),
- },
- },
- },
- 404: {
- description: "File not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { hash, name } = context.req.valid("param");
- const { range } = context.req.valid("header");
-
- // parse `Range` header
- const [start = 0, end = Number.POSITIVE_INFINITY] = (
- range
- .split("=") // ["Range: bytes", "0-100"]
- .at(-1) || ""
- ) // "0-100"
- .split("-") // ["0", "100"]
- .map(Number); // [0, 100]
-
- // Serve file from filesystem
- const file = Bun.file(`./uploads/${hash}/${name}`);
-
- const buffer = await file.arrayBuffer();
-
- if (!(await file.exists())) {
- throw new ApiError(404, "File not found");
- }
-
- // Can't directly copy file into Response because this crashes Bun for now
- return context.body(buffer, 200, {
- "Content-Type": file.type || "application/octet-stream",
- "Content-Length": `${file.size - start}`,
- "Content-Range": `bytes ${start}-${end}/${file.size}`,
- // biome-ignore lint/suspicious/noExplicitAny: Hono doesn't type this response so this has a TS error
- }) as any;
- }),
-);
diff --git a/api/media/[hash]/[name]/index.ts b/api/media/[hash]/[name]/index.ts
new file mode 100644
index 00000000..7b96fcfe
--- /dev/null
+++ b/api/media/[hash]/[name]/index.ts
@@ -0,0 +1,77 @@
+import { apiRoute, handleZodError } from "@/api";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/media/:hash/:name",
+ describeRoute({
+ summary: "Get media file by hash and name",
+ responses: {
+ 200: {
+ description: "Media",
+ content: {
+ "*": {
+ schema: resolver(z.any()),
+ },
+ },
+ },
+ 404: {
+ description: "File not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ hash: z.string(),
+ name: z.string(),
+ }),
+ handleZodError,
+ ),
+ validator(
+ "header",
+ z.object({
+ range: z.string().optional().default(""),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { hash, name } = context.req.valid("param");
+ const { range } = context.req.valid("header");
+
+ // parse `Range` header
+ const [start = 0, end = Number.POSITIVE_INFINITY] = (
+ range
+ .split("=") // ["Range: bytes", "0-100"]
+ .at(-1) || ""
+ ) // "0-100"
+ .split("-") // ["0", "100"]
+ .map(Number); // [0, 100]
+
+ // Serve file from filesystem
+ const file = Bun.file(`./uploads/${hash}/${name}`);
+
+ const buffer = await file.arrayBuffer();
+
+ if (!(await file.exists())) {
+ throw new ApiError(404, "File not found");
+ }
+
+ // Can't directly copy file into Response because this crashes Bun for now
+ return context.body(buffer, 200, {
+ "Content-Type": file.type || "application/octet-stream",
+ "Content-Length": `${file.size - start}`,
+ "Content-Range": `bytes ${start}-${end}/${file.size}`,
+ // biome-ignore lint/suspicious/noExplicitAny: Hono doesn't type this response so this has a TS error
+ }) as any;
+ },
+ ),
+);
diff --git a/api/media/proxy/:id.ts b/api/media/proxy/:id.ts
deleted file mode 100644
index 9290baa6..00000000
--- a/api/media/proxy/:id.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { proxy } from "hono/proxy";
-import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status";
-import { ApiError } from "~/classes/errors/api-error";
-import { config } from "~/config.ts";
-
-const schemas = {
- param: z.object({
- id: z
- .string()
- .transform((val) => Buffer.from(val, "base64url").toString()),
- }),
-};
-
-const route = createRoute({
- method: "get",
- path: "/media/proxy/{id}",
- summary: "Proxy media through the server",
- request: {
- params: schemas.param,
- },
- responses: {
- 200: {
- description: "Media",
- content: {
- "*": {
- schema: z.any(),
- },
- },
- },
- 400: {
- description: "Invalid URL to proxy",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { id } = context.req.valid("param");
-
- // Check if URL is valid
- if (!URL.canParse(id)) {
- throw new ApiError(
- 400,
- "Invalid URL",
- "Should be encoded as base64url",
- );
- }
-
- const media = await proxy(id, {
- // @ts-expect-error Proxy is a Bun-specific feature
- proxy: config.http.proxy_address,
- });
-
- // Check if file extension ends in svg or svg
- // Cloudflare R2 serves those as application/xml
- if (
- media.headers.get("Content-Type") === "application/xml" &&
- id.endsWith(".svg")
- ) {
- media.headers.set("Content-Type", "image/svg+xml");
- }
-
- const realFilename =
- media.headers
- .get("Content-Disposition")
- ?.match(/filename="(.+)"/)?.[1] || id.split("/").pop();
-
- if (!media.body) {
- return context.body(null, media.status as StatusCode);
- }
-
- return context.body(media.body, media.status as ContentfulStatusCode, {
- "Content-Type":
- media.headers.get("Content-Type") || "application/octet-stream",
- "Content-Length": media.headers.get("Content-Length") || "0",
- "Content-Security-Policy": "",
- // Real filename
- "Content-Disposition": `inline; filename="${realFilename}"`,
- });
- }),
-);
diff --git a/api/media/proxy/[id].ts b/api/media/proxy/[id].ts
new file mode 100644
index 00000000..01c6259a
--- /dev/null
+++ b/api/media/proxy/[id].ts
@@ -0,0 +1,96 @@
+import { apiRoute, handleZodError } from "@/api";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { proxy } from "hono/proxy";
+import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+import { config } from "~/config.ts";
+
+export default apiRoute((app) =>
+ app.get(
+ "/media/proxy/{id}",
+ describeRoute({
+ summary: "Proxy media through the server",
+ responses: {
+ 200: {
+ description: "Media",
+ content: {
+ "*": {
+ schema: resolver(z.any()),
+ },
+ },
+ },
+ 400: {
+ description: "Invalid URL to proxy",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ id: z
+ .string()
+ .transform((val) =>
+ Buffer.from(val, "base64url").toString(),
+ ),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+
+ // Check if URL is valid
+ if (!URL.canParse(id)) {
+ throw new ApiError(
+ 400,
+ "Invalid URL",
+ "Should be encoded as base64url",
+ );
+ }
+
+ const media = await proxy(id, {
+ // @ts-expect-error Proxy is a Bun-specific feature
+ proxy: config.http.proxy_address,
+ });
+
+ // Check if file extension ends in svg or svg
+ // Cloudflare R2 serves those as application/xml
+ if (
+ media.headers.get("Content-Type") === "application/xml" &&
+ id.endsWith(".svg")
+ ) {
+ media.headers.set("Content-Type", "image/svg+xml");
+ }
+
+ const realFilename =
+ media.headers
+ .get("Content-Disposition")
+ ?.match(/filename="(.+)"/)?.[1] || id.split("/").pop();
+
+ if (!media.body) {
+ return context.body(null, media.status as StatusCode);
+ }
+
+ return context.body(
+ media.body,
+ media.status as ContentfulStatusCode,
+ {
+ "Content-Type":
+ media.headers.get("Content-Type") ||
+ "application/octet-stream",
+ "Content-Length":
+ media.headers.get("Content-Length") || "0",
+ "Content-Security-Policy": "",
+ // Real filename
+ "Content-Disposition": `inline; filename="${realFilename}"`,
+ },
+ );
+ },
+ ),
+);
diff --git a/api/messaging/index.ts b/api/messaging/index.ts
index 6d9f7aab..296e491e 100644
--- a/api/messaging/index.ts
+++ b/api/messaging/index.ts
@@ -1,37 +1,28 @@
import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
import { getLogger } from "@logtape/logtape";
import chalk from "chalk";
-
-const route = createRoute({
- method: "post",
- path: "/messaging",
- summary: "Endpoint for the Instance Messaging Versia Extension.",
- description: "https://versia.pub/extensions/instance-messaging.",
- tags: ["Federation"],
- request: {
- body: {
- content: {
- "text/plain": {
- schema: z.string(),
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Message saved",
- },
- },
-});
+import { describeRoute } from "hono-openapi";
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const content = await context.req.text();
+ app.post(
+ "/messaging",
+ describeRoute({
+ summary: "Endpoint for the Instance Messaging Versia Extension.",
+ description: "https://versia.pub/extensions/instance-messaging.",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Message saved",
+ },
+ },
+ }),
+ async (context) => {
+ const content = await context.req.text();
- getLogger(["federation", "messaging"])
- .info`Received message via ${chalk.bold("Instance Messaging")}:\n${content}`;
+ getLogger(["federation", "messaging"])
+ .info`Received message via ${chalk.bold("Instance Messaging")}:\n${content}`;
- return context.text("", 200);
- }),
+ return context.text("", 200);
+ },
+ ),
);
diff --git a/api/notes/:uuid/index.ts b/api/notes/:uuid/index.ts
deleted file mode 100644
index e02911ab..00000000
--- a/api/notes/:uuid/index.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { Note as NoteSchema } from "@versia/federation/schemas";
-import { Note } from "@versia/kit/db";
-import { Notes } from "@versia/kit/tables";
-import { and, eq, inArray } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-import { config } from "~/config.ts";
-
-const route = createRoute({
- method: "get",
- path: "/notes/{id}",
- summary: "Retrieve the Versia representation of a note.",
- tags: ["Federation"],
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- },
- responses: {
- 200: {
- description: "Note",
- content: {
- "application/json": {
- schema: NoteSchema,
- },
- },
- },
- 404: {
- description:
- "Entity not found, is remote, or the requester is not allowed to view it.",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { id } = context.req.valid("param");
-
- const note = await Note.fromSql(
- and(
- eq(Notes.id, id),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- );
-
- if (!(note && (await note.isViewableByUser(null))) || note.isRemote()) {
- throw ApiError.noteNotFound();
- }
-
- // If base_url uses https and request uses http, rewrite request to use https
- // This fixes reverse proxy errors
- const reqUrl = new URL(context.req.url);
- if (
- config.http.base_url.protocol === "https:" &&
- reqUrl.protocol === "http:"
- ) {
- reqUrl.protocol = "https:";
- }
-
- const { headers } = await note.author.sign(
- note.toVersia(),
- reqUrl,
- "GET",
- );
-
- return context.json(note.toVersia(), 200, headers.toJSON());
- }),
-);
diff --git a/api/notes/:uuid/quotes.ts b/api/notes/:uuid/quotes.ts
deleted file mode 100644
index 6650b6bd..00000000
--- a/api/notes/:uuid/quotes.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
-import type { URICollection } from "@versia/federation/types";
-import { Note, db } from "@versia/kit/db";
-import { Notes } from "@versia/kit/tables";
-import { and, eq, inArray } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-import { config } from "~/config.ts";
-
-const route = createRoute({
- method: "get",
- path: "/notes/{id}/quotes",
- summary: "Retrieve all quotes of a Versia Note.",
- tags: ["Federation"],
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- query: z.object({
- limit: z.coerce.number().int().min(1).max(100).default(40),
- offset: z.coerce.number().int().nonnegative().default(0),
- }),
- },
- responses: {
- 200: {
- description: "Note quotes",
- content: {
- "application/json": {
- schema: URICollectionSchema,
- },
- },
- },
- 404: {
- description:
- "Entity not found, is remote, or the requester is not allowed to view it.",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { id } = context.req.valid("param");
- const { limit, offset } = context.req.valid("query");
-
- const note = await Note.fromSql(
- and(
- eq(Notes.id, id),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- );
-
- if (!(note && (await note.isViewableByUser(null))) || note.isRemote()) {
- throw ApiError.noteNotFound();
- }
-
- const replies = await Note.manyFromSql(
- and(
- eq(Notes.quotingId, note.id),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- undefined,
- limit,
- offset,
- );
-
- const replyCount = await db.$count(
- Notes,
- and(
- eq(Notes.quotingId, note.id),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- );
-
- const uriCollection = {
- author: note.author.getUri().href,
- first: new URL(
- `/notes/${note.id}/quotes?offset=0`,
- config.http.base_url,
- ).href,
- last:
- replyCount > limit
- ? new URL(
- `/notes/${note.id}/quotes?offset=${replyCount - limit}`,
- config.http.base_url,
- ).href
- : new URL(`/notes/${note.id}/quotes`, config.http.base_url)
- .href,
- next:
- offset + limit < replyCount
- ? new URL(
- `/notes/${note.id}/quotes?offset=${offset + limit}`,
- config.http.base_url,
- ).href
- : null,
- previous:
- offset - limit >= 0
- ? new URL(
- `/notes/${note.id}/quotes?offset=${offset - limit}`,
- config.http.base_url,
- ).href
- : null,
- total: replyCount,
- items: replies.map((reply) => reply.getUri().href),
- } satisfies URICollection;
-
- // If base_url uses https and request uses http, rewrite request to use https
- // This fixes reverse proxy errors
- const reqUrl = new URL(context.req.url);
- if (
- config.http.base_url.protocol === "https:" &&
- reqUrl.protocol === "http:"
- ) {
- reqUrl.protocol = "https:";
- }
-
- const { headers } = await note.author.sign(
- uriCollection,
- reqUrl,
- "GET",
- );
-
- return context.json(uriCollection, 200, headers.toJSON());
- }),
-);
diff --git a/api/notes/:uuid/replies.ts b/api/notes/:uuid/replies.ts
deleted file mode 100644
index 490bb4d7..00000000
--- a/api/notes/:uuid/replies.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { Status as StatusSchema } from "@versia/client/schemas";
-import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
-import type { URICollection } from "@versia/federation/types";
-import { Note, db } from "@versia/kit/db";
-import { Notes } from "@versia/kit/tables";
-import { and, eq, inArray } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-import { config } from "~/config.ts";
-
-const route = createRoute({
- method: "get",
- path: "/notes/{id}/replies",
- summary: "Retrieve all replies to a Versia Note.",
- tags: ["Federation"],
- request: {
- params: z.object({
- id: StatusSchema.shape.id,
- }),
- query: z.object({
- limit: z.coerce.number().int().min(1).max(100).default(40),
- offset: z.coerce.number().int().nonnegative().default(0),
- }),
- },
- responses: {
- 200: {
- description: "Note replies",
- content: {
- "application/json": {
- schema: URICollectionSchema,
- },
- },
- },
- 404: {
- description:
- "Entity not found, is remote, or the requester is not allowed to view it.",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { id } = context.req.valid("param");
- const { limit, offset } = context.req.valid("query");
-
- const note = await Note.fromSql(
- and(
- eq(Notes.id, id),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- );
-
- if (!(note && (await note.isViewableByUser(null))) || note.isRemote()) {
- throw ApiError.noteNotFound();
- }
-
- const replies = await Note.manyFromSql(
- and(
- eq(Notes.replyId, note.id),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- undefined,
- limit,
- offset,
- );
-
- const replyCount = await db.$count(
- Notes,
- and(
- eq(Notes.replyId, note.id),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- );
-
- const uriCollection = {
- author: note.author.getUri().href,
- first: new URL(
- `/notes/${note.id}/replies?offset=0`,
- config.http.base_url,
- ).href,
- last:
- replyCount > limit
- ? new URL(
- `/notes/${note.id}/replies?offset=${replyCount - limit}`,
- config.http.base_url,
- ).href
- : new URL(`/notes/${note.id}/replies`, config.http.base_url)
- .href,
- next:
- offset + limit < replyCount
- ? new URL(
- `/notes/${note.id}/replies?offset=${offset + limit}`,
- config.http.base_url,
- ).href
- : null,
- previous:
- offset - limit >= 0
- ? new URL(
- `/notes/${note.id}/replies?offset=${offset - limit}`,
- config.http.base_url,
- ).href
- : null,
- total: replyCount,
- items: replies.map((reply) => reply.getUri().href),
- } satisfies URICollection;
-
- // If base_url uses https and request uses http, rewrite request to use https
- // This fixes reverse proxy errors
- const reqUrl = new URL(context.req.url);
- if (
- config.http.base_url.protocol === "https:" &&
- reqUrl.protocol === "http:"
- ) {
- reqUrl.protocol = "https:";
- }
-
- const { headers } = await note.author.sign(
- uriCollection,
- reqUrl,
- "GET",
- );
-
- return context.json(uriCollection, 200, headers.toJSON());
- }),
-);
diff --git a/api/notes/[uuid]/index.ts b/api/notes/[uuid]/index.ts
new file mode 100644
index 00000000..677dacf2
--- /dev/null
+++ b/api/notes/[uuid]/index.ts
@@ -0,0 +1,82 @@
+import { apiRoute, handleZodError } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { Note as NoteSchema } from "@versia/federation/schemas";
+import { Note } from "@versia/kit/db";
+import { Notes } from "@versia/kit/tables";
+import { and, eq, inArray } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+import { config } from "~/config.ts";
+
+export default apiRoute((app) =>
+ app.get(
+ "/notes/:id",
+ describeRoute({
+ summary: "Retrieve the Versia representation of a note.",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Note",
+ content: {
+ "application/json": {
+ schema: resolver(NoteSchema),
+ },
+ },
+ },
+ 404: {
+ description:
+ "Entity not found, is remote, or the requester is not allowed to view it.",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ id: StatusSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+
+ const note = await Note.fromSql(
+ and(
+ eq(Notes.id, id),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ );
+
+ if (
+ !(note && (await note.isViewableByUser(null))) ||
+ note.isRemote()
+ ) {
+ throw ApiError.noteNotFound();
+ }
+
+ // If base_url uses https and request uses http, rewrite request to use https
+ // This fixes reverse proxy errors
+ const reqUrl = new URL(context.req.url);
+ if (
+ config.http.base_url.protocol === "https:" &&
+ reqUrl.protocol === "http:"
+ ) {
+ reqUrl.protocol = "https:";
+ }
+
+ const { headers } = await note.author.sign(
+ note.toVersia(),
+ reqUrl,
+ "GET",
+ );
+
+ return context.json(note.toVersia(), 200, headers.toJSON());
+ },
+ ),
+);
diff --git a/api/notes/[uuid]/quotes.ts b/api/notes/[uuid]/quotes.ts
new file mode 100644
index 00000000..0a53e5c9
--- /dev/null
+++ b/api/notes/[uuid]/quotes.ts
@@ -0,0 +1,144 @@
+import { apiRoute, handleZodError } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
+import type { URICollection } from "@versia/federation/types";
+import { Note, db } from "@versia/kit/db";
+import { Notes } from "@versia/kit/tables";
+import { and, eq, inArray } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+import { config } from "~/config.ts";
+
+export default apiRoute((app) =>
+ app.get(
+ "/notes/:id/quotes",
+ describeRoute({
+ summary: "Retrieve all quotes of a Versia Note.",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Note quotes",
+ content: {
+ "application/json": {
+ schema: resolver(URICollectionSchema),
+ },
+ },
+ },
+ 404: {
+ description:
+ "Entity not found, is remote, or the requester is not allowed to view it.",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ id: StatusSchema.shape.id,
+ }),
+ handleZodError,
+ ),
+ validator(
+ "query",
+ z.object({
+ limit: z.coerce.number().int().min(1).max(100).default(40),
+ offset: z.coerce.number().int().nonnegative().default(0),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+ const { limit, offset } = context.req.valid("query");
+
+ const note = await Note.fromSql(
+ and(
+ eq(Notes.id, id),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ );
+
+ if (
+ !(note && (await note.isViewableByUser(null))) ||
+ note.isRemote()
+ ) {
+ throw ApiError.noteNotFound();
+ }
+
+ const replies = await Note.manyFromSql(
+ and(
+ eq(Notes.quotingId, note.id),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ undefined,
+ limit,
+ offset,
+ );
+
+ const replyCount = await db.$count(
+ Notes,
+ and(
+ eq(Notes.quotingId, note.id),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ );
+
+ const uriCollection = {
+ author: note.author.getUri().href,
+ first: new URL(
+ `/notes/${note.id}/quotes?offset=0`,
+ config.http.base_url,
+ ).href,
+ last:
+ replyCount > limit
+ ? new URL(
+ `/notes/${note.id}/quotes?offset=${replyCount - limit}`,
+ config.http.base_url,
+ ).href
+ : new URL(
+ `/notes/${note.id}/quotes`,
+ config.http.base_url,
+ ).href,
+ next:
+ offset + limit < replyCount
+ ? new URL(
+ `/notes/${note.id}/quotes?offset=${offset + limit}`,
+ config.http.base_url,
+ ).href
+ : null,
+ previous:
+ offset - limit >= 0
+ ? new URL(
+ `/notes/${note.id}/quotes?offset=${offset - limit}`,
+ config.http.base_url,
+ ).href
+ : null,
+ total: replyCount,
+ items: replies.map((reply) => reply.getUri().href),
+ } satisfies URICollection;
+
+ // If base_url uses https and request uses http, rewrite request to use https
+ // This fixes reverse proxy errors
+ const reqUrl = new URL(context.req.url);
+ if (
+ config.http.base_url.protocol === "https:" &&
+ reqUrl.protocol === "http:"
+ ) {
+ reqUrl.protocol = "https:";
+ }
+
+ const { headers } = await note.author.sign(
+ uriCollection,
+ reqUrl,
+ "GET",
+ );
+
+ return context.json(uriCollection, 200, headers.toJSON());
+ },
+ ),
+);
diff --git a/api/notes/[uuid]/replies.ts b/api/notes/[uuid]/replies.ts
new file mode 100644
index 00000000..bc04e127
--- /dev/null
+++ b/api/notes/[uuid]/replies.ts
@@ -0,0 +1,142 @@
+import { apiRoute, handleZodError } from "@/api";
+import { Status as StatusSchema } from "@versia/client/schemas";
+import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
+import type { URICollection } from "@versia/federation/types";
+import { Note, db } from "@versia/kit/db";
+import { Notes } from "@versia/kit/tables";
+import { and, eq, inArray } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+import { config } from "~/config.ts";
+
+export default apiRoute((app) =>
+ app.get(
+ "/notes/:id/replies",
+ describeRoute({
+ summary: "Retrieve all replies to a Versia Note.",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Note replies",
+ content: {
+ "application/json": {
+ schema: resolver(URICollectionSchema),
+ },
+ },
+ },
+ 404: {
+ description:
+ "Entity not found, is remote, or the requester is not allowed to view it.",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "param",
+ z.object({ id: StatusSchema.shape.id }),
+ handleZodError,
+ ),
+ validator(
+ "query",
+ z.object({
+ limit: z.coerce.number().int().min(1).max(100).default(40),
+ offset: z.coerce.number().int().nonnegative().default(0),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { id } = context.req.valid("param");
+ const { limit, offset } = context.req.valid("query");
+
+ const note = await Note.fromSql(
+ and(
+ eq(Notes.id, id),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ );
+
+ if (
+ !(note && (await note.isViewableByUser(null))) ||
+ note.isRemote()
+ ) {
+ throw ApiError.noteNotFound();
+ }
+
+ const replies = await Note.manyFromSql(
+ and(
+ eq(Notes.replyId, note.id),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ undefined,
+ limit,
+ offset,
+ );
+
+ const replyCount = await db.$count(
+ Notes,
+ and(
+ eq(Notes.replyId, note.id),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ );
+
+ const uriCollection = {
+ author: note.author.getUri().href,
+ first: new URL(
+ `/notes/${note.id}/replies?offset=0`,
+ config.http.base_url,
+ ).href,
+ last:
+ replyCount > limit
+ ? new URL(
+ `/notes/${note.id}/replies?offset=${replyCount - limit}`,
+ config.http.base_url,
+ ).href
+ : new URL(
+ `/notes/${note.id}/replies`,
+ config.http.base_url,
+ ).href,
+ next:
+ offset + limit < replyCount
+ ? new URL(
+ `/notes/${note.id}/replies?offset=${offset + limit}`,
+ config.http.base_url,
+ ).href
+ : null,
+ previous:
+ offset - limit >= 0
+ ? new URL(
+ `/notes/${note.id}/replies?offset=${offset - limit}`,
+ config.http.base_url,
+ ).href
+ : null,
+ total: replyCount,
+ items: replies.map((reply) => reply.getUri().href),
+ } satisfies URICollection;
+
+ // If base_url uses https and request uses http, rewrite request to use https
+ // This fixes reverse proxy errors
+ const reqUrl = new URL(context.req.url);
+ if (
+ config.http.base_url.protocol === "https:" &&
+ reqUrl.protocol === "http:"
+ ) {
+ reqUrl.protocol = "https:";
+ }
+
+ const { headers } = await note.author.sign(
+ uriCollection,
+ reqUrl,
+ "GET",
+ );
+
+ return context.json(uriCollection, 200, headers.toJSON());
+ },
+ ),
+);
diff --git a/api/users/:uuid/inbox/index.ts b/api/users/:uuid/inbox/index.ts
deleted file mode 100644
index 2079927e..00000000
--- a/api/users/:uuid/inbox/index.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import type { Entity } from "@versia/federation/types";
-import { ApiError } from "~/classes/errors/api-error";
-import { InboxJobType, inboxQueue } from "~/classes/queues/inbox";
-
-const schemas = {
- param: z.object({
- uuid: z.string().uuid(),
- }),
- header: z.object({
- "versia-signature": z.string().optional(),
- "versia-signed-at": z.coerce.number().optional(),
- "versia-signed-by": z
- .string()
- .url()
- .or(z.string().startsWith("instance "))
- .optional(),
- authorization: z.string().optional(),
- }),
- body: z.any(),
-};
-
-const route = createRoute({
- method: "post",
- path: "/users/{uuid}/inbox",
- summary: "Receive federation inbox",
- tags: ["Federation"],
- request: {
- params: schemas.param,
- headers: schemas.header,
- body: {
- content: {
- "application/json": {
- schema: schemas.body,
- },
- },
- },
- },
- responses: {
- 200: {
- description: "Request processed",
- },
- 201: {
- description: "Request accepted",
- },
- 400: {
- description: "Bad request",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 401: {
- description: "Signature could not be verified",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 403: {
- description: "Cannot view users from remote instances",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 404: {
- description: "Not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 500: {
- description: "Internal server error",
- content: {
- "application/json": {
- schema: z.object({
- error: z.string(),
- message: z.string(),
- }),
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const body: Entity = await context.req.valid("json");
-
- await inboxQueue.add(InboxJobType.ProcessEntity, {
- data: body,
- headers: context.req.valid("header"),
- request: {
- body: await context.req.text(),
- method: context.req.method,
- url: context.req.url,
- },
- ip: context.env.ip ?? null,
- });
-
- return context.body(
- "Request processing initiated.\nImplement the Instance Messaging Extension to receive any eventual feedback (errors, etc.)",
- 200,
- );
- }),
-);
diff --git a/api/users/:uuid/index.ts b/api/users/:uuid/index.ts
deleted file mode 100644
index 5f335897..00000000
--- a/api/users/:uuid/index.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import { User as UserSchema } from "@versia/federation/schemas";
-import { User } from "@versia/kit/db";
-import { ApiError } from "~/classes/errors/api-error";
-
-const schemas = {
- param: z.object({
- uuid: z.string().uuid(),
- }),
-};
-
-const route = createRoute({
- method: "get",
- path: "/users/{uuid}",
- summary: "Get user data",
- request: {
- params: schemas.param,
- },
- tags: ["Federation"],
- responses: {
- 200: {
- description: "User data",
- content: {
- "application/json": {
- schema: UserSchema,
- },
- },
- },
- 301: {
- description:
- "Redirect to user profile (for web browsers). Uses user-agent for detection.",
- },
- 404: ApiError.accountNotFound().schema,
- 403: {
- description: "Cannot view users from remote instances",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { uuid } = context.req.valid("param");
-
- const user = await User.fromId(uuid);
-
- if (!user) {
- throw ApiError.accountNotFound();
- }
-
- if (user.isRemote()) {
- throw new ApiError(403, "User is not on this instance");
- }
-
- // Try to detect a web browser and redirect to the user's profile page
- if (context.req.header("user-agent")?.includes("Mozilla")) {
- return context.redirect(user.toApi().url);
- }
-
- const userJson = user.toVersia();
-
- const { headers } = await user.sign(
- userJson,
- new URL(context.req.url),
- "GET",
- );
-
- return context.json(userJson, 200, headers.toJSON());
- }),
-);
diff --git a/api/users/:uuid/outbox/index.ts b/api/users/:uuid/outbox/index.ts
deleted file mode 100644
index e4e5034e..00000000
--- a/api/users/:uuid/outbox/index.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
-import {
- Collection as CollectionSchema,
- Note as NoteSchema,
-} from "@versia/federation/schemas";
-import { Note, User, db } from "@versia/kit/db";
-import { Notes } from "@versia/kit/tables";
-import { and, eq, inArray } from "drizzle-orm";
-import { ApiError } from "~/classes/errors/api-error";
-import { config } from "~/config.ts";
-
-const schemas = {
- param: z.object({
- uuid: z.string().uuid(),
- }),
- query: z.object({
- page: z.string().optional(),
- }),
-};
-
-const route = createRoute({
- method: "get",
- path: "/users/{uuid}/outbox",
- summary: "Get user outbox",
- request: {
- params: schemas.param,
- query: schemas.query,
- },
- tags: ["Federation"],
- responses: {
- 200: {
- description: "User outbox",
- content: {
- "application/json": {
- schema: CollectionSchema.extend({
- items: z.array(NoteSchema),
- }),
- },
- },
- },
- 404: {
- description: "User not found",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- 403: {
- description: "Cannot view users from remote instances",
- content: {
- "application/json": {
- schema: ApiError.zodSchema,
- },
- },
- },
- },
-});
-
-const NOTES_PER_PAGE = 20;
-
-export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { uuid } = context.req.valid("param");
-
- const author = await User.fromId(uuid);
-
- if (!author) {
- throw new ApiError(404, "User not found");
- }
-
- if (author.isRemote()) {
- throw new ApiError(403, "User is not on this instance");
- }
-
- const pageNumber = Number(context.req.valid("query").page) || 1;
-
- const notes = await Note.manyFromSql(
- and(
- eq(Notes.authorId, uuid),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- undefined,
- NOTES_PER_PAGE,
- NOTES_PER_PAGE * (pageNumber - 1),
- );
-
- const totalNotes = await db.$count(
- Notes,
- and(
- eq(Notes.authorId, uuid),
- inArray(Notes.visibility, ["public", "unlisted"]),
- ),
- );
-
- const json = {
- first: new URL(
- `/users/${uuid}/outbox?page=1`,
- config.http.base_url,
- ).toString(),
- last: new URL(
- `/users/${uuid}/outbox?page=${Math.ceil(
- totalNotes / NOTES_PER_PAGE,
- )}`,
- config.http.base_url,
- ).toString(),
- total: totalNotes,
- author: author.getUri().toString(),
- next:
- notes.length === NOTES_PER_PAGE
- ? new URL(
- `/users/${uuid}/outbox?page=${pageNumber + 1}`,
- config.http.base_url,
- ).toString()
- : null,
- previous:
- pageNumber > 1
- ? new URL(
- `/users/${uuid}/outbox?page=${pageNumber - 1}`,
- config.http.base_url,
- ).toString()
- : null,
- items: notes.map((note) => note.toVersia()),
- };
-
- const { headers } = await author.sign(
- json,
- new URL(context.req.url),
- "GET",
- );
-
- return context.json(json, 200, headers.toJSON());
- }),
-);
diff --git a/api/users/[uuid]/inbox/index.ts b/api/users/[uuid]/inbox/index.ts
new file mode 100644
index 00000000..024dcfa8
--- /dev/null
+++ b/api/users/[uuid]/inbox/index.ts
@@ -0,0 +1,111 @@
+import { apiRoute, handleZodError } from "@/api";
+import type { Entity } from "@versia/federation/types";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+import { InboxJobType, inboxQueue } from "~/classes/queues/inbox";
+
+export default apiRoute((app) =>
+ app.post(
+ "/users/:uuid/inbox",
+ describeRoute({
+ summary: "Receive federation inbox",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Request processed",
+ },
+ 201: {
+ description: "Request accepted",
+ },
+ 400: {
+ description: "Bad request",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 401: {
+ description: "Signature could not be verified",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 403: {
+ description: "Cannot view users from remote instances",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.object({
+ error: z.string(),
+ message: z.string(),
+ }),
+ ),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ uuid: z.string().uuid(),
+ }),
+ handleZodError,
+ ),
+ validator(
+ "header",
+ z.object({
+ "versia-signature": z.string().optional(),
+ "versia-signed-at": z.coerce.number().optional(),
+ "versia-signed-by": z
+ .string()
+ .url()
+ .or(z.string().startsWith("instance "))
+ .optional(),
+ authorization: z.string().optional(),
+ }),
+ handleZodError,
+ ),
+ validator("json", z.any(), handleZodError),
+ async (context) => {
+ const body: Entity = await context.req.valid("json");
+
+ await inboxQueue.add(InboxJobType.ProcessEntity, {
+ data: body,
+ headers: context.req.valid("header"),
+ request: {
+ body: await context.req.text(),
+ method: context.req.method,
+ url: context.req.url,
+ },
+ ip: context.env.ip ?? null,
+ });
+
+ return context.body(
+ "Request processing initiated.\nImplement the Instance Messaging Extension to receive any eventual feedback (errors, etc.)",
+ 200,
+ );
+ },
+ ),
+);
diff --git a/api/users/[uuid]/index.ts b/api/users/[uuid]/index.ts
new file mode 100644
index 00000000..263b968b
--- /dev/null
+++ b/api/users/[uuid]/index.ts
@@ -0,0 +1,75 @@
+import { apiRoute, handleZodError } from "@/api";
+import { User as UserSchema } from "@versia/federation/schemas";
+import { User } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+
+export default apiRoute((app) =>
+ app.get(
+ "/users/{uuid}",
+ describeRoute({
+ summary: "Get user data",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "User data",
+ content: {
+ "application/json": {
+ schema: resolver(UserSchema),
+ },
+ },
+ },
+ 301: {
+ description:
+ "Redirect to user profile (for web browsers). Uses user-agent for detection.",
+ },
+ 404: ApiError.accountNotFound().schema,
+ 403: {
+ description: "Cannot view users from remote instances",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ uuid: z.string().uuid(),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { uuid } = context.req.valid("param");
+
+ const user = await User.fromId(uuid);
+
+ if (!user) {
+ throw ApiError.accountNotFound();
+ }
+
+ if (user.isRemote()) {
+ throw new ApiError(403, "User is not on this instance");
+ }
+
+ // Try to detect a web browser and redirect to the user's profile page
+ if (context.req.header("user-agent")?.includes("Mozilla")) {
+ return context.redirect(user.toApi().url);
+ }
+
+ const userJson = user.toVersia();
+
+ const { headers } = await user.sign(
+ userJson,
+ new URL(context.req.url),
+ "GET",
+ );
+
+ return context.json(userJson, 200, headers.toJSON());
+ },
+ ),
+);
diff --git a/api/users/[uuid]/outbox/index.ts b/api/users/[uuid]/outbox/index.ts
new file mode 100644
index 00000000..a15a6127
--- /dev/null
+++ b/api/users/[uuid]/outbox/index.ts
@@ -0,0 +1,138 @@
+import { apiRoute, handleZodError } from "@/api";
+import {
+ Collection as CollectionSchema,
+ Note as NoteSchema,
+} from "@versia/federation/schemas";
+import { Note, User, db } from "@versia/kit/db";
+import { Notes } from "@versia/kit/tables";
+import { and, eq, inArray } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
+import { ApiError } from "~/classes/errors/api-error";
+import { config } from "~/config.ts";
+
+const NOTES_PER_PAGE = 20;
+
+export default apiRoute((app) =>
+ app.get(
+ "/users/:uuid/outbox",
+ describeRoute({
+ summary: "Get user outbox",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "User outbox",
+ content: {
+ "application/json": {
+ schema: CollectionSchema.extend({
+ items: z.array(NoteSchema),
+ }),
+ },
+ },
+ },
+ 404: {
+ description: "User not found",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ 403: {
+ description: "Cannot view users from remote instances",
+ content: {
+ "application/json": {
+ schema: resolver(ApiError.zodSchema),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ uuid: z.string().uuid(),
+ }),
+ handleZodError,
+ ),
+ validator(
+ "query",
+ z.object({
+ page: z.string().optional(),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { uuid } = context.req.valid("param");
+
+ const author = await User.fromId(uuid);
+
+ if (!author) {
+ throw new ApiError(404, "User not found");
+ }
+
+ if (author.isRemote()) {
+ throw new ApiError(403, "User is not on this instance");
+ }
+
+ const pageNumber = Number(context.req.valid("query").page) || 1;
+
+ const notes = await Note.manyFromSql(
+ and(
+ eq(Notes.authorId, uuid),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ undefined,
+ NOTES_PER_PAGE,
+ NOTES_PER_PAGE * (pageNumber - 1),
+ );
+
+ const totalNotes = await db.$count(
+ Notes,
+ and(
+ eq(Notes.authorId, uuid),
+ inArray(Notes.visibility, ["public", "unlisted"]),
+ ),
+ );
+
+ const json = {
+ first: new URL(
+ `/users/${uuid}/outbox?page=1`,
+ config.http.base_url,
+ ).toString(),
+ last: new URL(
+ `/users/${uuid}/outbox?page=${Math.ceil(
+ totalNotes / NOTES_PER_PAGE,
+ )}`,
+ config.http.base_url,
+ ).toString(),
+ total: totalNotes,
+ author: author.getUri().toString(),
+ next:
+ notes.length === NOTES_PER_PAGE
+ ? new URL(
+ `/users/${uuid}/outbox?page=${pageNumber + 1}`,
+ config.http.base_url,
+ ).toString()
+ : null,
+ previous:
+ pageNumber > 1
+ ? new URL(
+ `/users/${uuid}/outbox?page=${pageNumber - 1}`,
+ config.http.base_url,
+ ).toString()
+ : null,
+ items: notes.map((note) => note.toVersia()),
+ };
+
+ const { headers } = await author.sign(
+ json,
+ new URL(context.req.url),
+ "GET",
+ );
+
+ return context.json(json, 200, headers.toJSON());
+ },
+ ),
+);
diff --git a/api/well-known/host-meta/index.ts b/api/well-known/host-meta/index.ts
index 90b6667b..1e454436 100644
--- a/api/well-known/host-meta/index.ts
+++ b/api/well-known/host-meta/index.ts
@@ -1,36 +1,38 @@
import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { z } from "zod";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "get",
- path: "/.well-known/host-meta",
- summary: "Well-known host-meta",
- tags: ["Federation"],
- responses: {
- 200: {
- description: "Host-meta",
- content: {
- "application/xrd+xml": {
- schema: z.any(),
+export default apiRoute((app) =>
+ app.get(
+ "/.well-known/host-meta",
+ describeRoute({
+ summary: "Well-known host-meta",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Host-meta",
+ content: {
+ "application/xrd+xml": {
+ schema: resolver(z.any()),
+ },
+ },
},
},
+ }),
+ (context) => {
+ context.header("Content-Type", "application/xrd+xml");
+ context.status(200);
+
+ return context.body(
+ ` `,
+ 200,
+ // biome-ignore lint/suspicious/noExplicitAny: Hono doesn't type this response so this has a TS error, it's joever
+ ) as any;
},
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, (context) => {
- context.header("Content-Type", "application/xrd+xml");
- context.status(200);
-
- return context.body(
- ` `,
- 200,
- // biome-ignore lint/suspicious/noExplicitAny: Hono doesn't type this response so this has a TS error, it's joever
- ) as any;
- }),
+ ),
);
diff --git a/api/well-known/nodeinfo/2.0/index.ts b/api/well-known/nodeinfo/2.0/index.ts
index 6bac532a..b64a03f5 100644
--- a/api/well-known/nodeinfo/2.0/index.ts
+++ b/api/well-known/nodeinfo/2.0/index.ts
@@ -1,76 +1,80 @@
import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
import { Note, User } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { z } from "zod";
import { config } from "~/config.ts";
import manifest from "~/package.json";
-const route = createRoute({
- method: "get",
- path: "/.well-known/nodeinfo/2.0",
- summary: "Well-known nodeinfo 2.0",
- tags: ["Federation"],
- responses: {
- 200: {
- description: "Nodeinfo 2.0",
- content: {
- "application/json": {
- schema: z.object({
- version: z.string(),
- software: z.object({
- name: z.string(),
- version: z.string(),
- }),
- protocols: z.array(z.string()),
- services: z.object({
- outbound: z.array(z.string()),
- inbound: z.array(z.string()),
- }),
- usage: z.object({
- users: z.object({
- total: z.number(),
- activeMonth: z.number(),
- activeHalfyear: z.number(),
- }),
- localPosts: z.number(),
- }),
- openRegistrations: z.boolean(),
- metadata: z.object({}),
- }),
- },
- },
- },
- },
-});
-
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const userCount = await User.getCount();
- const userActiveMonth = await User.getActiveInPeriod(
- 1000 * 60 * 60 * 24 * 30,
- );
- const userActiveHalfyear = await User.getActiveInPeriod(
- 1000 * 60 * 60 * 24 * 30 * 6,
- );
- const noteCount = await Note.getCount();
-
- return context.json({
- version: "2.0",
- software: { name: "versia-server", version: manifest.version },
- protocols: ["versia"],
- services: { outbound: [], inbound: [] },
- usage: {
- users: {
- total: userCount,
- activeMonth: userActiveMonth,
- activeHalfyear: userActiveHalfyear,
+ app.get(
+ "/.well-known/nodeinfo/2.0",
+ describeRoute({
+ summary: "Well-known nodeinfo 2.0",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Nodeinfo 2.0",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.object({
+ version: z.string(),
+ software: z.object({
+ name: z.string(),
+ version: z.string(),
+ }),
+ protocols: z.array(z.string()),
+ services: z.object({
+ outbound: z.array(z.string()),
+ inbound: z.array(z.string()),
+ }),
+ usage: z.object({
+ users: z.object({
+ total: z.number(),
+ activeMonth: z.number(),
+ activeHalfyear: z.number(),
+ }),
+ localPosts: z.number(),
+ }),
+ openRegistrations: z.boolean(),
+ metadata: z.object({}),
+ }),
+ ),
+ },
+ },
},
- localPosts: noteCount,
},
- openRegistrations: config.registration.allow,
- metadata: {
- nodeName: config.instance.name,
- nodeDescription: config.instance.description,
- },
- });
- }),
+ }),
+ async (context) => {
+ const userCount = await User.getCount();
+ const userActiveMonth = await User.getActiveInPeriod(
+ 1000 * 60 * 60 * 24 * 30,
+ );
+ const userActiveHalfyear = await User.getActiveInPeriod(
+ 1000 * 60 * 60 * 24 * 30 * 6,
+ );
+ const noteCount = await Note.getCount();
+
+ return context.json({
+ version: "2.0",
+ software: { name: "versia-server", version: manifest.version },
+ protocols: ["versia"],
+ services: { outbound: [], inbound: [] },
+ usage: {
+ users: {
+ total: userCount,
+ activeMonth: userActiveMonth,
+ activeHalfyear: userActiveHalfyear,
+ },
+ localPosts: noteCount,
+ },
+ openRegistrations: config.registration.allow,
+ metadata: {
+ nodeName: config.instance.name,
+ nodeDescription: config.instance.description,
+ },
+ });
+ },
+ ),
);
diff --git a/api/well-known/nodeinfo/index.ts b/api/well-known/nodeinfo/index.ts
index 1ba94b2e..a9b329bf 100644
--- a/api/well-known/nodeinfo/index.ts
+++ b/api/well-known/nodeinfo/index.ts
@@ -1,43 +1,47 @@
import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { z } from "zod";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "get",
- path: "/.well-known/nodeinfo",
- summary: "Well-known nodeinfo",
- tags: ["Federation"],
- responses: {
- 200: {
- description: "Nodeinfo links",
- content: {
- "application/json": {
- schema: z.object({
- links: z.array(
- z.object({
- rel: z.string(),
- href: z.string(),
- }),
- ),
- }),
+export default apiRoute((app) =>
+ app.get(
+ "/.well-known/nodeinfo",
+ describeRoute({
+ summary: "Well-known nodeinfo",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Nodeinfo links",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.object({
+ links: z.array(
+ z.object({
+ rel: z.string(),
+ href: z.string(),
+ }),
+ ),
+ }),
+ ),
+ },
+ },
},
},
+ }),
+ (context) => {
+ return context.json({
+ links: [
+ {
+ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
+ href: new URL(
+ "/.well-known/nodeinfo/2.0",
+ config.http.base_url,
+ ).toString(),
+ },
+ ],
+ });
},
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, (context) => {
- return context.json({
- links: [
- {
- rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
- href: new URL(
- "/.well-known/nodeinfo/2.0",
- config.http.base_url,
- ).toString(),
- },
- ],
- });
- }),
+ ),
);
diff --git a/api/well-known/openid-configuration/index.ts b/api/well-known/openid-configuration/index.ts
index 499e1523..cdd78677 100644
--- a/api/well-known/openid-configuration/index.ts
+++ b/api/well-known/openid-configuration/index.ts
@@ -1,58 +1,66 @@
import { apiRoute } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
+import { z } from "zod";
import { config } from "~/config.ts";
-const route = createRoute({
- method: "get",
- path: "/.well-known/openid-configuration",
- summary: "OpenID Configuration",
- tags: ["OpenID"],
- responses: {
- 200: {
- description: "OpenID Configuration",
- content: {
- "application/json": {
- schema: z.object({
- issuer: z.string(),
- authorization_endpoint: z.string(),
- token_endpoint: z.string(),
- userinfo_endpoint: z.string(),
- jwks_uri: z.string(),
- response_types_supported: z.array(z.string()),
- subject_types_supported: z.array(z.string()),
- id_token_signing_alg_values_supported: z.array(
- z.string(),
- ),
- scopes_supported: z.array(z.string()),
- token_endpoint_auth_methods_supported: z.array(
- z.string(),
- ),
- claims_supported: z.array(z.string()),
- }),
+export default apiRoute((app) =>
+ app.get(
+ "/.well-known/openid-configuration",
+ describeRoute({
+ summary: "OpenID Configuration",
+ tags: ["OpenID"],
+ responses: {
+ 200: {
+ description: "OpenID Configuration",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.object({
+ issuer: z.string(),
+ authorization_endpoint: z.string(),
+ token_endpoint: z.string(),
+ userinfo_endpoint: z.string(),
+ jwks_uri: z.string(),
+ response_types_supported: z.array(
+ z.string(),
+ ),
+ subject_types_supported: z.array(
+ z.string(),
+ ),
+ id_token_signing_alg_values_supported:
+ z.array(z.string()),
+ scopes_supported: z.array(z.string()),
+ token_endpoint_auth_methods_supported:
+ z.array(z.string()),
+ claims_supported: z.array(z.string()),
+ }),
+ ),
+ },
+ },
},
},
+ }),
+ (context) => {
+ const baseUrl = config.http.base_url;
+ return context.json(
+ {
+ issuer: baseUrl.origin.toString(),
+ authorization_endpoint: `${baseUrl.origin}/oauth/authorize`,
+ token_endpoint: `${baseUrl.origin}/oauth/token`,
+ userinfo_endpoint: `${baseUrl.origin}/api/v1/accounts/verify_credentials`,
+ jwks_uri: `${baseUrl.origin}/.well-known/jwks`,
+ response_types_supported: ["code"],
+ subject_types_supported: ["public"],
+ id_token_signing_alg_values_supported: ["EdDSA"],
+ scopes_supported: ["openid", "profile", "email"],
+ token_endpoint_auth_methods_supported: [
+ "client_secret_basic",
+ ],
+ claims_supported: ["sub"],
+ },
+ 200,
+ );
},
- },
-});
-
-export default apiRoute((app) =>
- app.openapi(route, (context) => {
- const baseUrl = config.http.base_url;
- return context.json(
- {
- issuer: baseUrl.origin.toString(),
- authorization_endpoint: `${baseUrl.origin}/oauth/authorize`,
- token_endpoint: `${baseUrl.origin}/oauth/token`,
- userinfo_endpoint: `${baseUrl.origin}/api/v1/accounts/verify_credentials`,
- jwks_uri: `${baseUrl.origin}/.well-known/jwks`,
- response_types_supported: ["code"],
- subject_types_supported: ["public"],
- id_token_signing_alg_values_supported: ["EdDSA"],
- scopes_supported: ["openid", "profile", "email"],
- token_endpoint_auth_methods_supported: ["client_secret_basic"],
- claims_supported: ["sub"],
- },
- 200,
- );
- }),
+ ),
);
diff --git a/api/well-known/versia.ts b/api/well-known/versia.ts
index ea48cfc5..8e01a684 100644
--- a/api/well-known/versia.ts
+++ b/api/well-known/versia.ts
@@ -1,83 +1,90 @@
import { apiRoute } from "@/api";
import { urlToContentFormat } from "@/content_types";
-import { createRoute } from "@hono/zod-openapi";
import { InstanceMetadata as InstanceMetadataSchema } from "@versia/federation/schemas";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { asc } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { config } from "~/config.ts";
import pkg from "~/package.json";
-const route = createRoute({
- method: "get",
- path: "/.well-known/versia",
- summary: "Get instance metadata",
- tags: ["Federation"],
- responses: {
- 200: {
- description: "Instance metadata",
- content: {
- "application/json": {
- schema: InstanceMetadataSchema,
- },
- },
- },
- },
-});
-
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- // Get date of first user creation
- const firstUser = await User.fromSql(undefined, asc(Users.createdAt));
-
- const publicKey = Buffer.from(
- await crypto.subtle.exportKey("spki", config.instance.keys.public),
- ).toString("base64");
-
- return context.json(
- {
- type: "InstanceMetadata" as const,
- compatibility: {
- extensions: [
- "pub.versia:custom_emojis",
- "pub.versia:instance_messaging",
- ],
- versions: ["0.5.0"],
- },
- host: config.http.base_url.host,
- name: config.instance.name,
- description: config.instance.description,
- public_key: {
- key: publicKey,
- algorithm: "ed25519" as const,
- },
- software: {
- name: "Versia Server",
- version: pkg.version,
- },
- banner: config.instance.branding.banner
- ? urlToContentFormat(config.instance.branding.banner)
- : undefined,
- logo: config.instance.branding.logo
- ? urlToContentFormat(config.instance.branding.logo)
- : undefined,
- shared_inbox: new URL(
- "/inbox",
- config.http.base_url,
- ).toString(),
- created_at: new Date(
- firstUser?.data.createdAt ?? 0,
- ).toISOString(),
- extensions: {
- "pub.versia:instance_messaging": {
- endpoint: new URL(
- "/messaging",
- config.http.base_url,
- ).toString(),
+ app.get(
+ "/.well-known/versia",
+ describeRoute({
+ summary: "Get instance metadata",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "Instance metadata",
+ content: {
+ "application/json": {
+ schema: resolver(InstanceMetadataSchema),
+ },
},
},
},
- 200,
- );
- }),
+ }),
+ async (context) => {
+ // Get date of first user creation
+ const firstUser = await User.fromSql(
+ undefined,
+ asc(Users.createdAt),
+ );
+
+ const publicKey = Buffer.from(
+ await crypto.subtle.exportKey(
+ "spki",
+ config.instance.keys.public,
+ ),
+ ).toString("base64");
+
+ return context.json(
+ {
+ type: "InstanceMetadata" as const,
+ compatibility: {
+ extensions: [
+ "pub.versia:custom_emojis",
+ "pub.versia:instance_messaging",
+ ],
+ versions: ["0.5.0"],
+ },
+ host: config.http.base_url.host,
+ name: config.instance.name,
+ description: config.instance.description,
+ public_key: {
+ key: publicKey,
+ algorithm: "ed25519" as const,
+ },
+ software: {
+ name: "Versia Server",
+ version: pkg.version,
+ },
+ banner: config.instance.branding.banner
+ ? urlToContentFormat(config.instance.branding.banner)
+ : undefined,
+ logo: config.instance.branding.logo
+ ? urlToContentFormat(config.instance.branding.logo)
+ : undefined,
+ shared_inbox: new URL(
+ "/inbox",
+ config.http.base_url,
+ ).toString(),
+ created_at: new Date(
+ firstUser?.data.createdAt ?? 0,
+ ).toISOString(),
+ extensions: {
+ "pub.versia:instance_messaging": {
+ endpoint: new URL(
+ "/messaging",
+ config.http.base_url,
+ ).toString(),
+ },
+ },
+ },
+ 200,
+ );
+ },
+ ),
);
diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts
index 5087b517..b8b34dce 100644
--- a/api/well-known/webfinger/index.ts
+++ b/api/well-known/webfinger/index.ts
@@ -1,143 +1,144 @@
import {
apiRoute,
+ handleZodError,
idValidator,
parseUserAddress,
webfingerMention,
} from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
import { getLogger } from "@logtape/logtape";
import type { ResponseError } from "@versia/federation";
import { WebFinger } from "@versia/federation/schemas";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
-const schemas = {
- query: z.object({
- resource: z
- .string()
- .trim()
- .min(1)
- .max(512)
- .startsWith("acct:")
- .regex(
- webfingerMention,
- "Invalid resource (should be acct:(id or username)@domain)",
- ),
- }),
-};
-
-const route = createRoute({
- method: "get",
- path: "/.well-known/webfinger",
- summary: "Get user information",
- request: {
- query: schemas.query,
- },
- tags: ["Federation"],
- responses: {
- 200: {
- description: "User information",
- content: {
- "application/json": {
- schema: WebFinger,
- },
- },
- },
- 404: ApiError.accountNotFound().schema,
- },
-});
-
export default apiRoute((app) =>
- app.openapi(route, async (context) => {
- const { resource } = context.req.valid("query");
-
- const requestedUser = resource.split("acct:")[1];
-
- const host = config.http.base_url.host;
-
- const { username, domain } = parseUserAddress(requestedUser);
-
- // Check if user is a local user
- if (domain !== host) {
- throw new ApiError(
- 404,
- `User domain ${domain} does not match ${host}`,
- );
- }
-
- const isUuid = username.match(idValidator);
-
- const user = await User.fromSql(
- and(
- eq(isUuid ? Users.id : Users.username, username),
- isNull(Users.instanceId),
- ),
- );
-
- if (!user) {
- throw ApiError.accountNotFound();
- }
-
- let activityPubUrl = "";
-
- if (config.federation.bridge) {
- const manager = await User.getFederationRequester();
-
- try {
- activityPubUrl = await manager.webFinger(
- user.data.username,
- config.http.base_url.host,
- "application/activity+json",
- config.federation.bridge.url.origin,
- );
- } catch (e) {
- const error = e as ResponseError;
-
- getLogger(["federation", "bridge"])
- .error`Error from bridge: ${await error.response.data}`;
- }
- }
-
- return context.json(
- {
- subject: `acct:${isUuid ? user.id : user.data.username}@${host}`,
-
- links: [
- // Keep the ActivityPub link first, because Misskey only searches
- // for the first link with rel="self" and doesn't check the type.
- activityPubUrl
- ? {
- rel: "self",
- type: "application/activity+json",
- href: activityPubUrl,
- }
- : undefined,
- {
- rel: "self",
- type: "application/json",
- href: new URL(
- `/users/${user.id}`,
- config.http.base_url,
- ).toString(),
+ app.get(
+ "/.well-known/webfinger",
+ describeRoute({
+ summary: "Get user information",
+ tags: ["Federation"],
+ responses: {
+ 200: {
+ description: "User information",
+ content: {
+ "application/json": {
+ schema: resolver(WebFinger),
+ },
},
- {
- rel: "avatar",
- // Default avatars are SVGs
- type:
- user.avatar?.getPreferredMimeType() ??
- "image/svg+xml",
- href: user.getAvatarUrl(),
- },
- ].filter(Boolean) as {
- rel: string;
- type: string;
- href: string;
- }[],
+ },
+ 404: ApiError.accountNotFound().schema,
},
- 200,
- );
- }),
+ }),
+ validator(
+ "query",
+ z.object({
+ resource: z
+ .string()
+ .trim()
+ .min(1)
+ .max(512)
+ .startsWith("acct:")
+ .regex(
+ webfingerMention,
+ "Invalid resource (should be acct:(id or username)@domain)",
+ ),
+ }),
+ handleZodError,
+ ),
+ async (context) => {
+ const { resource } = context.req.valid("query");
+
+ const requestedUser = resource.split("acct:")[1];
+
+ const host = config.http.base_url.host;
+
+ const { username, domain } = parseUserAddress(requestedUser);
+
+ // Check if user is a local user
+ if (domain !== host) {
+ throw new ApiError(
+ 404,
+ `User domain ${domain} does not match ${host}`,
+ );
+ }
+
+ const isUuid = username.match(idValidator);
+
+ const user = await User.fromSql(
+ and(
+ eq(isUuid ? Users.id : Users.username, username),
+ isNull(Users.instanceId),
+ ),
+ );
+
+ if (!user) {
+ throw ApiError.accountNotFound();
+ }
+
+ let activityPubUrl = "";
+
+ if (config.federation.bridge) {
+ const manager = await User.getFederationRequester();
+
+ try {
+ activityPubUrl = await manager.webFinger(
+ user.data.username,
+ config.http.base_url.host,
+ "application/activity+json",
+ config.federation.bridge.url.origin,
+ );
+ } catch (e) {
+ const error = e as ResponseError;
+
+ getLogger(["federation", "bridge"])
+ .error`Error from bridge: ${await error.response.data}`;
+ }
+ }
+
+ return context.json(
+ {
+ subject: `acct:${isUuid ? user.id : user.data.username}@${host}`,
+
+ links: [
+ // Keep the ActivityPub link first, because Misskey only searches
+ // for the first link with rel="self" and doesn't check the type.
+ activityPubUrl
+ ? {
+ rel: "self",
+ type: "application/activity+json",
+ href: activityPubUrl,
+ }
+ : undefined,
+ {
+ rel: "self",
+ type: "application/json",
+ href: new URL(
+ `/users/${user.id}`,
+ config.http.base_url,
+ ).toString(),
+ },
+ {
+ rel: "avatar",
+ // Default avatars are SVGs
+ type:
+ user.avatar?.getPreferredMimeType() ??
+ "image/svg+xml",
+ href: user.getAvatarUrl(),
+ },
+ ].filter(Boolean) as {
+ rel: string;
+ type: string;
+ href: string;
+ }[],
+ },
+ 200,
+ );
+ },
+ ),
);
diff --git a/app.ts b/app.ts
index 01ae0450..7830a02d 100644
--- a/app.ts
+++ b/app.ts
@@ -1,13 +1,13 @@
import { join } from "node:path";
-import { handleZodError } from "@/api";
import { applyToHono } from "@/bull-board.ts";
import { configureLoggers } from "@/loggers";
import { sentry } from "@/sentry";
-import { OpenAPIHono } from "@hono/zod-openapi";
/* import { prometheus } from "@hono/prometheus"; */
import { getLogger } from "@logtape/logtape";
import { apiReference } from "@scalar/hono-api-reference";
import chalk from "chalk";
+import { Hono } from "hono";
+import { openAPISpecs } from "hono-openapi";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import { createMiddleware } from "hono/factory";
@@ -25,14 +25,15 @@ import { logger } from "./middlewares/logger.ts";
import { rateLimit } from "./middlewares/rate-limit.ts";
import { routes } from "./routes.ts";
import type { ApiRouteExports, HonoEnv } from "./types/api.ts";
+// Extends Zod with OpenAPI schema generation
+import "zod-openapi/extend";
-export const appFactory = async (): Promise> => {
+export const appFactory = async (): Promise> => {
await configureLoggers();
const serverLogger = getLogger("server");
- const app = new OpenAPIHono({
+ const app = new Hono({
strict: false,
- defaultHook: handleZodError,
});
app.use(ipBans);
@@ -134,30 +135,23 @@ export const appFactory = async (): Promise> => {
(time2 - time1).toFixed(2),
)}ms`}`;
- app.doc31("/openapi.json", {
- openapi: "3.1.0",
- info: {
- title: "Versia Server API",
- version: pkg.version,
- license: {
- name: "AGPL-3.0",
- url: "https://www.gnu.org/licenses/agpl-3.0.html",
+ app.get(
+ "/openapi.json",
+ openAPISpecs(app, {
+ documentation: {
+ info: {
+ title: "Versia Server API",
+ version: pkg.version,
+ license: {
+ name: "AGPL-3.0",
+ url: "https://www.gnu.org/licenses/agpl-3.0.html",
+ },
+ contact: pkg.author,
+ },
},
- contact: pkg.author,
- },
- });
- app.doc("/openapi.3.0.0.json", {
- openapi: "3.0.0",
- info: {
- title: "Versia Server API",
- version: pkg.version,
- license: {
- name: "AGPL-3.0",
- url: "https://www.gnu.org/licenses/agpl-3.0.html",
- },
- contact: pkg.author,
- },
- });
+ }),
+ );
+
app.get(
"/docs",
apiReference({
diff --git a/benchmarks/timeline.ts b/benchmarks/timeline.ts
index eef72e22..1e8fbac0 100644
--- a/benchmarks/timeline.ts
+++ b/benchmarks/timeline.ts
@@ -1,7 +1,7 @@
import { configureLoggers } from "@/loggers";
-import type { z } from "@hono/zod-openapi";
import type { Status } from "@versia/client/schemas";
import { bench, run } from "mitata";
+import type { z } from "zod";
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
await configureLoggers(true);
diff --git a/bun.lock b/bun.lock
index 2141cd95..9cf06258 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,7 +13,6 @@
"@clerc/plugin-version": "^0.44.0",
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@hono/prometheus": "^1.0.1",
- "@hono/zod-openapi": "0.19.2",
"@hono/zod-validator": "^0.4.3",
"@inquirer/confirm": "^5.1.8",
"@logtape/file": "^0.9.0",
@@ -32,6 +31,7 @@
"confbox": "^0.2.1",
"drizzle-orm": "^0.41.0",
"hono": "^4.7.5",
+ "hono-openapi": "^0.4.6",
"hono-rate-limiter": "^0.4.2",
"html-to-text": "^9.0.5",
"ioredis": "^5.6.0",
@@ -61,6 +61,7 @@
"xss": "^1.0.15",
"youch": "^4.1.0-beta.6",
"zod": "^3.24.2",
+ "zod-openapi": "^4.2.3",
"zod-validation-error": "^3.4.0",
},
"devDependencies": {
@@ -92,7 +93,6 @@
"version": "0.2.0-alpha.1",
"dependencies": {
"@badgateway/oauth2-client": "^2.4.2",
- "@hono/zod-openapi": "^0.19.2",
},
},
"packages/plugin-kit": {
@@ -115,9 +115,6 @@
"patchedDependencies": {
"@bull-board/api@6.7.10": "patches/@bull-board%2Fapi@6.5.3.patch",
},
- "overrides": {
- "zod": "^3.24.1",
- },
"packages": {
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
@@ -127,33 +124,33 @@
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
- "@algolia/client-abtesting": ["@algolia/client-abtesting@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-I239aSmXa3pXDhp3AWGaIfesqJBNFA7drUM8SIfNxMIzvQXUnHRf4rW1o77QXLI/nIClNsb8KOLaB62gO9LnlQ=="],
+ "@algolia/client-abtesting": ["@algolia/client-abtesting@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-AyZ+9CUgWXwaaJ2lSwOJSy+/w0MFBPFqLrjWYs/HEpYMzBuFfGNZ7gEM9a7h4j7jY8hSBARBl8qdvInmj5vOEQ=="],
- "@algolia/client-analytics": ["@algolia/client-analytics@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-OxoUfeG9G4VE4gS7B4q65KkHzdGsQsDwxQfR5J9uKB8poSGuNlHJWsF3ABqCkc5VliAR0m8KMjsQ9o/kOpEGnQ=="],
+ "@algolia/client-analytics": ["@algolia/client-analytics@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-oeKCPwLBnTEPF/RWr0aaJnrfRDfFRLT5O7KV0OF1NmpEXvmzLmN7RwnwDKsNtPUHNfpJ6esP9xzkPEtJabrZ2w=="],
- "@algolia/client-common": ["@algolia/client-common@5.21.0", "", {}, "sha512-iHLgDQFyZNe9M16vipbx6FGOA8NoMswHrfom/QlCGoyh7ntjGvfMb+J2Ss8rRsAlOWluv8h923Ku3QVaB0oWDQ=="],
+ "@algolia/client-common": ["@algolia/client-common@5.23.0", "", {}, "sha512-9jacdC44vXLSaYKNLkFpbU1J4BbBPi/N7uoPhcGO//8ubRuVzigH6+RfK5FbudmQlqFt0J5DGUCVeTlHtgyUeg=="],
- "@algolia/client-insights": ["@algolia/client-insights@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-y7XBO9Iwb75FLDl95AYcWSLIViJTpR5SUUCyKsYhpP9DgyUqWbISqDLXc96TS9shj+H+7VsTKA9cJK8NUfVN6g=="],
+ "@algolia/client-insights": ["@algolia/client-insights@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-/Gw5UitweRsnyb24Td4XhjXmsx8PxFzCI0oW6FZZvyr4kjzB9ECP2IjO+PdDq1A2fzDl/LXQ+u8ROudoVnXnQg=="],
- "@algolia/client-personalization": ["@algolia/client-personalization@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-6KU658lD9Tss4oCX6c/O15tNZxw7vR+WAUG95YtZzYG/KGJHTpy2uckqbMmC2cEK4a86FAq4pH5azSJ7cGMjuw=="],
+ "@algolia/client-personalization": ["@algolia/client-personalization@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-ivrEZBoXfDatpqpifgHauydxHEe4udNqJ0gy7adR2KODeQ+39MQeaT10I24mu+eylIuiQKJRqORgEdLZycq2qQ=="],
- "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-pG6MyVh1v0X+uwrKHn3U+suHdgJ2C+gug+UGkNHfMELHMsEoWIAQhxMBOFg7hCnWBFjQnuq6qhM3X9X5QO3d9Q=="],
+ "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-DjSgJWqTcsnlXEKqDsU7Y2vB/W/VYLlr6UfkzJkMuKB554Ia7IJr4keP2AlHVjjbBG62IDpdh5OkEs/+fbWsOA=="],
- "@algolia/client-search": ["@algolia/client-search@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-nZfgJH4njBK98tFCmCW1VX/ExH4bNOl9DSboxeXGgvhoL0fG1+4DDr/mrLe21OggVCQqHwXBMh6fFInvBeyhiQ=="],
+ "@algolia/client-search": ["@algolia/client-search@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-XAYWUYUhEG4OIdo/N7H/OFFRD9fokfv3bBTky+4Y4/q07bxhnrGSUvcrU6JQ2jJTQyg6kv0ke1EIfiTO/Xxb+g=="],
- "@algolia/ingestion": ["@algolia/ingestion@1.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-k6MZxLbZphGN5uRri9J/krQQBjUrqNcScPh985XXEFXbSCRvOPKVtjjLdVjGVHXXPOQgKrIZHxIdRNbHS+wVuA=="],
+ "@algolia/ingestion": ["@algolia/ingestion@1.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-ULbykzzhhLVofCDU1m/CqSzTyKmjaxA/z1d6o6hgUuR6X7/dll9/G0lu0e4vmWIOItklWWrhU2V8sXD0YGBIHg=="],
- "@algolia/monitoring": ["@algolia/monitoring@1.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-FiW5nnmyHvaGdorqLClw3PM6keXexAMiwbwJ9xzQr4LcNefLG3ln82NafRPgJO/z0dETAOKjds5aSmEFMiITHQ=="],
+ "@algolia/monitoring": ["@algolia/monitoring@1.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-oB3wG7CgQJQr+uoijV7bWBphiSHkvGX43At8RGgkDyc7Aeabcp9ik5HgLC1YDgbHVOlQI+tce5HIbDCifzQCIg=="],
- "@algolia/recommend": ["@algolia/recommend@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-+JXavbbliaLmah5QNgc/TDW/+r0ALa+rGhg5Y7+pF6GpNnzO0L+nlUaDNE8QbiJfz54F9BkwFUnJJeRJAuzTFw=="],
+ "@algolia/recommend": ["@algolia/recommend@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-4PWvCV6VGhnCMAbv2zfQUAlc3ofMs6ovqKlC/xcp7tWaucYd//piHg9CcCM4S0p9OZznEGQMRYPt2uqbk6V9vg=="],
- "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0" } }, "sha512-Iw+Yj5hOmo/iixHS94vEAQ3zi5GPpJywhfxn1el/zWo4AvPIte/+1h9Ywgw/+3M7YBj4jgAkScxjxQCxzLBsjA=="],
+ "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0" } }, "sha512-bacOsX41pnsupNB0k0Ny+1JDchQxIsZIcp69GKDBT0NgTHG8OayEO141eFalNmGil+GXPY0NUPRpx+5s4RdhGA=="],
- "@algolia/requester-fetch": ["@algolia/requester-fetch@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0" } }, "sha512-Z00SRLlIFj3SjYVfsd9Yd3kB3dUwQFAkQG18NunWP7cix2ezXpJqA+xAoEf9vc4QZHdxU3Gm8gHAtRiM2iVaTQ=="],
+ "@algolia/requester-fetch": ["@algolia/requester-fetch@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0" } }, "sha512-tVNFREexJWDrvc23evmRgAcb2KLZuVilOIB/rVnQCl0GDbqIWJuQ1lG22HKqvCEQFthHkgVFGLYE74wQ96768g=="],
- "@algolia/requester-node-http": ["@algolia/requester-node-http@5.21.0", "", { "dependencies": { "@algolia/client-common": "5.21.0" } }, "sha512-WqU0VumUILrIeVYCTGZlyyZoC/tbvhiyPxfGRRO1cSjxN558bnJLlR2BvS0SJ5b75dRNK7HDvtXo2QoP9eLfiA=="],
+ "@algolia/requester-node-http": ["@algolia/requester-node-http@5.23.0", "", { "dependencies": { "@algolia/client-common": "5.23.0" } }, "sha512-XXHbq2heOZc9EFCc4z+uyHS9YRBygZbYQVsWjWZWx8hdAz+tkBX/jLHM9Xg+3zO0/v8JN6pcZzqYEVsdrLeNLg=="],
- "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-7tE/r1gXwMIvGnXVUdIqUhCU1RevEFC4Jk6Bussa0fk1ecbnnINkZzj1EOAJyE/M3AI25DnHT/zKQL1/FPFi8Q=="],
+ "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="],
"@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="],
@@ -161,11 +158,11 @@
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="],
- "@babel/parser": ["@babel/parser@7.26.10", "", { "dependencies": { "@babel/types": "^7.26.10" }, "bin": "./bin/babel-parser.js" }, "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA=="],
+ "@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="],
- "@babel/runtime": ["@babel/runtime@7.26.10", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="],
+ "@babel/runtime": ["@babel/runtime@7.27.0", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw=="],
- "@babel/types": ["@babel/types@7.26.10", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ=="],
+ "@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="],
"@badgateway/oauth2-client": ["@badgateway/oauth2-client@2.4.2", "", {}, "sha512-70Fmzlmn8EfCjjssls8N6E94quBUWnLhu4inPZU2pkwpc6ZvbErkLRvtkYl81KFCvVcuVC0X10QPZVNwjXo2KA=="],
@@ -217,7 +214,7 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
- "@emnapi/runtime": ["@emnapi/runtime@1.3.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw=="],
+ "@emnapi/runtime": ["@emnapi/runtime@1.4.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@@ -273,8 +270,6 @@
"@hono/prometheus": ["@hono/prometheus@1.0.1", "", { "peerDependencies": { "hono": ">=3.*", "prom-client": "^15.0.0" } }, "sha512-PjMbjAppCgbvRP2aLxqJc1XJLxfmg4dLsS5R5ITt7qCf9Ab/xSRul/LHNVvYK2/ECi3BOPprSnlSizksBJmXBQ=="],
- "@hono/zod-openapi": ["@hono/zod-openapi@0.19.2", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.1.0", "@hono/zod-validator": "^0.4.1" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "3.*" } }, "sha512-lkFa6wdQVgY7d7/m++Ixr3hvKCF5Y+zjTIPM37fex5ylCfX53A/W28gZRDuFZx3aR+noKob7lHfwdk9dURLzxw=="],
-
"@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="],
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.29", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-KYrxmxtRz6iOAulRiUsIBMUuXek+H+Evwf8UvYPIkbQ+KDoOqTegHx3q/w3GDDVC0qJYB+D3hXPMZcpm78qIuA=="],
@@ -333,6 +328,8 @@
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
+ "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
+
"@logtape/file": ["@logtape/file@0.9.0", "", {}, "sha512-ipZAyEbAEggOej2QPj2oF4h95gzPzIamQWZyBMuzZV0h+fthEDZrgp3UdZ4Cdvl1rzuLoU5nRx7h4iiJVFltQw=="],
"@logtape/logtape": ["@logtape/logtape@0.9.0", "", {}, "sha512-e4IlinGvjzp/+nSvsXB1OPSYNiuVEEJy8aMQqbveTcJoLVRsJK7nH0xVh/EdNTjRBoioJbUT/jzxaAifxf1VyA=="],
@@ -433,51 +430,53 @@
"@prisma/instrumentation": ["@prisma/instrumentation@6.5.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-morJDtFRoAp5d/KENEm+K6Y3PQcn5bCvpJ5a9y3V3DNMrNy/ZSn2zulPGj+ld+Xj2UYVoaMJ8DpBX/o6iF6OiA=="],
- "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.36.0", "", { "os": "android", "cpu": "arm" }, "sha512-jgrXjjcEwN6XpZXL0HUeOVGfjXhPyxAbbhD0BlXUB+abTOpbPiN5Wb3kOT7yb+uEtATNYF5x5gIfwutmuBA26w=="],
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.37.0", "", { "os": "android", "cpu": "arm" }, "sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ=="],
- "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.36.0", "", { "os": "android", "cpu": "arm64" }, "sha512-NyfuLvdPdNUfUNeYKUwPwKsE5SXa2J6bCt2LdB/N+AxShnkpiczi3tcLJrm5mA+eqpy0HmaIY9F6XCa32N5yzg=="],
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.37.0", "", { "os": "android", "cpu": "arm64" }, "sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA=="],
- "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.36.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JQ1Jk5G4bGrD4pWJQzWsD8I1n1mgPXq33+/vP4sk8j/z/C2siRuxZtaUA7yMTf71TCZTZl/4e1bfzwUmFb3+rw=="],
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.37.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA=="],
- "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.36.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6c6wMZa1lrtiRsbDziCmjE53YbTkxMYhhnWnSW8R/yqsM7a6mSJ3uAVT0t8Y/DGt7gxUWYuFM4bwWk9XCJrFKA=="],
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.37.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ=="],
- "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.36.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-KXVsijKeJXOl8QzXTsA+sHVDsFOmMCdBRgFmBb+mfEb/7geR7+C8ypAml4fquUt14ZyVXaw2o1FWhqAfOvA4sg=="],
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.37.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA=="],
- "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.36.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dVeWq1ebbvByI+ndz4IJcD4a09RJgRYmLccwlQ8bPd4olz3Y213uf1iwvc7ZaxNn2ab7bjc08PrtBgMu6nb4pQ=="],
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.37.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA=="],
- "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.36.0", "", { "os": "linux", "cpu": "arm" }, "sha512-bvXVU42mOVcF4le6XSjscdXjqx8okv4n5vmwgzcmtvFdifQ5U4dXFYaCB87namDRKlUL9ybVtLQ9ztnawaSzvg=="],
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.37.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w=="],
- "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.36.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JFIQrDJYrxOnyDQGYkqnNBtjDwTgbasdbUiQvcU8JmGDfValfH1lNpng+4FWlhaVIR4KPkeddYjsVVbmJYvDcg=="],
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.37.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag=="],
- "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.36.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-KqjYVh3oM1bj//5X7k79PSCZ6CvaVzb7Qs7VMWS+SlWB5M8p3FqufLP9VNp4CazJ0CsPDLwVD9r3vX7Ci4J56A=="],
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.37.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA=="],
- "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.36.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QiGnhScND+mAAtfHqeT+cB1S9yFnNQ/EwCg5yE3MzoaZZnIV0RV9O5alJAoJKX/sBONVKeZdMfO8QSaWEygMhw=="],
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.37.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ=="],
- "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.36.0", "", { "os": "linux", "cpu": "none" }, "sha512-1ZPyEDWF8phd4FQtTzMh8FQwqzvIjLsl6/84gzUxnMNFBtExBtpL51H67mV9xipuxl1AEAerRBgBwFNpkw8+Lg=="],
+ "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.37.0", "", { "os": "linux", "cpu": "none" }, "sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA=="],
- "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.36.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VMPMEIUpPFKpPI9GZMhJrtu8rxnp6mJR3ZzQPykq4xc2GmdHj3Q4cA+7avMyegXy4n1v+Qynr9fR88BmyO74tg=="],
+ "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.37.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ=="],
- "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.36.0", "", { "os": "linux", "cpu": "none" }, "sha512-ttE6ayb/kHwNRJGYLpuAvB7SMtOeQnVXEIpMtAvx3kepFQeowVED0n1K9nAdraHUPJ5hydEMxBpIR7o4nrm8uA=="],
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.37.0", "", { "os": "linux", "cpu": "none" }, "sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw=="],
- "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.36.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4a5gf2jpS0AIe7uBjxDeUMNcFmaRTbNv7NxI5xOCs4lhzsVyGR/0qBXduPnoWf6dGC365saTiwag8hP1imTgag=="],
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.37.0", "", { "os": "linux", "cpu": "none" }, "sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA=="],
- "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.36.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5KtoW8UWmwFKQ96aQL3LlRXX16IMwyzMq/jSSVIIyAANiE1doaQsx/KRyhAvpHlPjPiSU/AYX/8m+lQ9VToxFQ=="],
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.37.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A=="],
- "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.36.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sycrYZPrv2ag4OCvaN5js+f01eoZ2U+RmT5as8vhxiFz+kxwlHrsxOwKPSA8WyS+Wc6Epid9QeI/IkQ9NkgYyQ=="],
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.37.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ=="],
- "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.36.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-qbqt4N7tokFwwSVlWDsjfoHgviS3n/vZ8LK0h1uLG9TYIRuUTJC88E1xb3LM2iqZ/WTqNQjYrtmtGmrmmawB6A=="],
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.37.0", "", { "os": "linux", "cpu": "x64" }, "sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w=="],
- "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.36.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-t+RY0JuRamIocMuQcfwYSOkmdX9dtkr1PbhKW42AMvaDQa+jOdpUYysroTF/nuPpAaQMWp7ye+ndlmmthieJrQ=="],
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.37.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg=="],
- "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.36.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aRXd7tRZkWLqGbChgcMMDEHjOKudo1kChb1Jt1IfR8cY/KIpgNviLeJy5FUb9IpSuQj8dU2fAYNMPW/hLKOSTw=="],
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.37.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA=="],
- "@scalar/core": ["@scalar/core@0.2.3", "", { "dependencies": { "@scalar/types": "0.1.3" } }, "sha512-o13vK5ThCZzkRp7fzFDzCcvzLJQz5d7Q2xLQbZ2FpeN/9L3uOFFkWabE2vO1kwz9q7baFq5lZKB0q4DPF+UW4Q=="],
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.37.0", "", { "os": "win32", "cpu": "x64" }, "sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA=="],
- "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.7.3", "", { "dependencies": { "@scalar/core": "0.2.3" }, "peerDependencies": { "hono": "^4.0.0" } }, "sha512-E7ArxL2YyA+yGzOuo794dsVfBLDpKycqUuJAV+V9BFXv1DCeT2dSjF8L7IhBgwMM/cx31O36juWVq2bcIJ4k7Q=="],
+ "@scalar/core": ["@scalar/core@0.2.4", "", { "dependencies": { "@scalar/types": "0.1.4" } }, "sha512-XGrg2P+FrvtzLsDm6TVl1oZv4DYmkFqJ3sI/SS2mjRlfOcblHv2CcrhYTz0+y1nG3cyHlE5esHAK0+BEI032dA=="],
+
+ "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.7.4", "", { "dependencies": { "@scalar/core": "0.2.4" }, "peerDependencies": { "hono": "^4.0.0" } }, "sha512-Uo2TpPdQbhnDkmTnVeTIXEXL30YY0GvBRgOo6mv1pIzZrYbTSJqxfg9um1/5wXZ6fh6w6GI3Y/tdu0GcMg5fJw=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="],
- "@scalar/types": ["@scalar/types@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.1.9", "@unhead/schema": "^1.11.11", "zod": "^3.23.8" } }, "sha512-Fxtgjp5wHhTzXiyODYWIoTsTy3oFC70vme+0I7MNwd8i6D8qplFNnpURueqBuP4MglBM2ZhFv3hPLw4D69anDA=="],
+ "@scalar/types": ["@scalar/types@0.1.4", "", { "dependencies": { "@scalar/openapi-types": "0.1.9", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-IAxpfrfdYfliLJR6WbuC8hxUwBUOeVsGuZxQE+zP8JDtdoHmgT6aNxCqMnGZ1ft6dvJ4jvzVR6qCWrq6Kg25oA=="],
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
@@ -521,6 +520,8 @@
"@types/html-to-text": ["@types/html-to-text@9.0.4", "", {}, "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ=="],
+ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
@@ -535,7 +536,7 @@
"@types/mysql": ["@types/mysql@2.15.26", "", { "dependencies": { "@types/node": "*" } }, "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ=="],
- "@types/node": ["@types/node@22.13.11", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-iEUCUJoU0i3VnrCmgoWCXttklWcvoCIx4jzcP22fioIVSdTmjgoEvmAO/QPw6TcS9k5FrNgn4w7q5lGOd1CT5g=="],
+ "@types/node": ["@types/node@22.13.14", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w=="],
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
@@ -557,7 +558,7 @@
"@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
- "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="],
+ "@types/ws": ["@types/ws@8.18.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
@@ -609,7 +610,7 @@
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
- "algoliasearch": ["algoliasearch@5.21.0", "", { "dependencies": { "@algolia/client-abtesting": "5.21.0", "@algolia/client-analytics": "5.21.0", "@algolia/client-common": "5.21.0", "@algolia/client-insights": "5.21.0", "@algolia/client-personalization": "5.21.0", "@algolia/client-query-suggestions": "5.21.0", "@algolia/client-search": "5.21.0", "@algolia/ingestion": "1.21.0", "@algolia/monitoring": "1.21.0", "@algolia/recommend": "5.21.0", "@algolia/requester-browser-xhr": "5.21.0", "@algolia/requester-fetch": "5.21.0", "@algolia/requester-node-http": "5.21.0" } }, "sha512-hexLq2lSO1K5SW9j21Ubc+q9Ptx7dyRTY7se19U8lhIlVMLCNXWCyQ6C22p9ez8ccX0v7QVmwkl2l1CnuGoO2Q=="],
+ "algoliasearch": ["algoliasearch@5.23.0", "", { "dependencies": { "@algolia/client-abtesting": "5.23.0", "@algolia/client-analytics": "5.23.0", "@algolia/client-common": "5.23.0", "@algolia/client-insights": "5.23.0", "@algolia/client-personalization": "5.23.0", "@algolia/client-query-suggestions": "5.23.0", "@algolia/client-search": "5.23.0", "@algolia/ingestion": "1.23.0", "@algolia/monitoring": "1.23.0", "@algolia/recommend": "5.23.0", "@algolia/requester-browser-xhr": "5.23.0", "@algolia/requester-fetch": "5.23.0", "@algolia/requester-node-http": "5.23.0" } }, "sha512-7TCj+hLx6fZKppLL74lYGDEltSBNSu4vqRwgqeIKZ3VQ0q3aOrdEN0f1sDWcvU1b+psn2wnl7aHt9hWtYatUUA=="],
"altcha-lib": ["altcha-lib@1.2.0", "", {}, "sha512-S5WF8QLNRaM1hvK24XPhOLfu9is2EBCvH7+nv50sM5CaIdUCqQCd0WV/qm/ZZFGTdSoKLuDp+IapZxBLvC+SNg=="],
@@ -685,6 +686,8 @@
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
+ "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
+
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"code-block-writer": ["code-block-writer@11.0.3", "", {}, "sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw=="],
@@ -855,6 +858,8 @@
"hono": ["hono@4.7.5", "", {}, "sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ=="],
+ "hono-openapi": ["hono-openapi@0.4.6", "", { "dependencies": { "json-schema-walker": "^2.0.0" }, "peerDependencies": { "@hono/arktype-validator": "^2.0.0", "@hono/effect-validator": "^1.2.0", "@hono/typebox-validator": "^0.2.0 || ^0.3.0", "@hono/valibot-validator": "^0.5.1", "@hono/zod-validator": "^0.4.1", "@sinclair/typebox": "^0.34.9", "@valibot/to-json-schema": "^1.0.0-beta.3", "arktype": "^2.0.0-rc.25", "effect": "^3.11.3", "hono": "^4.6.13", "openapi-types": "^12.1.3", "valibot": "^1.0.0-beta.9", "zod": "^3.23.8", "zod-openapi": "^4.0.0" }, "optionalPeers": ["@hono/arktype-validator", "@hono/effect-validator", "@hono/typebox-validator", "@hono/valibot-validator", "@hono/zod-validator", "@sinclair/typebox", "@valibot/to-json-schema", "arktype", "effect", "hono", "openapi-types", "valibot", "zod", "zod-openapi"] }, "sha512-wSDySp2cS5Zcf1OeLG7nCP3eMsCpcDomN137T9B6/Z5Qq3D0nWgMf0I3Gl41SE1rE37OBQ0Smqx3LOP9Hk//7A=="],
+
"hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
@@ -919,6 +924,8 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
+ "json-schema-walker": ["json-schema-walker@2.0.0", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.1.0", "clone": "^2.1.2" } }, "sha512-nXN2cMky0Iw7Af28w061hmxaPDaML5/bQD9nwm1lOoIKEGjHcRGxqWe4MfrkYThYAPjSUhmsp4bJNoLAyVn9Xw=="],
+
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"juice": ["juice@8.1.0", "", { "dependencies": { "cheerio": "1.0.0-rc.10", "commander": "^6.1.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^6.0.1" }, "bin": { "juice": "bin/juice" } }, "sha512-FLzurJrx5Iv1e7CfBSZH68dC04EEvXvvVvPYB7Vx1WAuhCp1ZPIMtqxc+WTWxVkpTIC2Ach/GAv0rQbtGf6YMA=="],
@@ -959,7 +966,7 @@
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
- "luxon": ["luxon@3.5.0", "", {}, "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ=="],
+ "luxon": ["luxon@3.6.0", "", {}, "sha512-WE7p0p7W1xji9qxkLYsvcIxZyfP48GuFrWIBQZIsbjCyf65dG1rv4n83HcOyEyhvzxJCrUoObCRNFgRNIQ5KNA=="],
"magic-regexp": ["magic-regexp@0.8.0", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.8", "mlly": "^1.6.1", "regexp-tree": "^0.1.27", "type-level-regexp": "~0.1.17", "ufo": "^1.4.0", "unplugin": "^1.8.3" } }, "sha512-lOSLWdE156csDYwCTIGiAymOLN7Epu/TU5e/oAnISZfU6qP+pgjkE+xbVjVn3yLPKN8n1G2yIAYTAM5KRk6/ow=="],
@@ -1069,8 +1076,6 @@
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
- "openapi3-ts": ["openapi3-ts@4.4.0", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw=="],
-
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
@@ -1185,7 +1190,7 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
- "rollup": ["rollup@4.36.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.36.0", "@rollup/rollup-android-arm64": "4.36.0", "@rollup/rollup-darwin-arm64": "4.36.0", "@rollup/rollup-darwin-x64": "4.36.0", "@rollup/rollup-freebsd-arm64": "4.36.0", "@rollup/rollup-freebsd-x64": "4.36.0", "@rollup/rollup-linux-arm-gnueabihf": "4.36.0", "@rollup/rollup-linux-arm-musleabihf": "4.36.0", "@rollup/rollup-linux-arm64-gnu": "4.36.0", "@rollup/rollup-linux-arm64-musl": "4.36.0", "@rollup/rollup-linux-loongarch64-gnu": "4.36.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.36.0", "@rollup/rollup-linux-riscv64-gnu": "4.36.0", "@rollup/rollup-linux-s390x-gnu": "4.36.0", "@rollup/rollup-linux-x64-gnu": "4.36.0", "@rollup/rollup-linux-x64-musl": "4.36.0", "@rollup/rollup-win32-arm64-msvc": "4.36.0", "@rollup/rollup-win32-ia32-msvc": "4.36.0", "@rollup/rollup-win32-x64-msvc": "4.36.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-zwATAXNQxUcd40zgtQG0ZafcRK4g004WtEl7kbuhTWPvf07PsfohXl39jVUvPF7jvNAIkKPQ2XrsDlWuxBd++Q=="],
+ "rollup": ["rollup@4.37.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.37.0", "@rollup/rollup-android-arm64": "4.37.0", "@rollup/rollup-darwin-arm64": "4.37.0", "@rollup/rollup-darwin-x64": "4.37.0", "@rollup/rollup-freebsd-arm64": "4.37.0", "@rollup/rollup-freebsd-x64": "4.37.0", "@rollup/rollup-linux-arm-gnueabihf": "4.37.0", "@rollup/rollup-linux-arm-musleabihf": "4.37.0", "@rollup/rollup-linux-arm64-gnu": "4.37.0", "@rollup/rollup-linux-arm64-musl": "4.37.0", "@rollup/rollup-linux-loongarch64-gnu": "4.37.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.37.0", "@rollup/rollup-linux-riscv64-gnu": "4.37.0", "@rollup/rollup-linux-riscv64-musl": "4.37.0", "@rollup/rollup-linux-s390x-gnu": "4.37.0", "@rollup/rollup-linux-x64-gnu": "4.37.0", "@rollup/rollup-linux-x64-musl": "4.37.0", "@rollup/rollup-win32-arm64-msvc": "4.37.0", "@rollup/rollup-win32-ia32-msvc": "4.37.0", "@rollup/rollup-win32-x64-msvc": "4.37.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -1291,7 +1296,7 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
- "type-fest": ["type-fest@4.37.0", "", {}, "sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg=="],
+ "type-fest": ["type-fest@4.38.0", "", {}, "sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg=="],
"type-flag": ["type-flag@3.0.0", "", {}, "sha512-3YaYwMseXCAhBB14RXW5cRQfJQlEknS6i4C8fCfeUdS3ihG9EdccdR9kt3vP73ZdeTGmPb4bZtkDn5XMIn1DLA=="],
@@ -1327,7 +1332,7 @@
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
- "vite": ["vite@5.4.14", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA=="],
+ "vite": ["vite@5.4.15", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA=="],
"vitepress": ["vitepress@1.6.3", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw=="],
@@ -1375,12 +1380,16 @@
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
+ "zod-openapi": ["zod-openapi@4.2.3", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-i0SqpcdXfsvVWTIY1Jl3Tk421s9fBIkpXvaA86zDas+8FjfZjm+GX6ot6SPB2SyuHwUNTN02gE5uIVlYXlyrDQ=="],
+
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
"zod-validation-error": ["zod-validation-error@3.4.0", "", { "peerDependencies": { "zod": "^3.18.0" } }, "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+ "@apidevtools/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
+
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
@@ -1397,6 +1406,8 @@
"@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+ "@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
+
"@ts-morph/common/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@versia/federation/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@@ -1439,8 +1450,6 @@
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
- "openapi3-ts/yaml": ["yaml@2.7.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA=="],
-
"ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
diff --git a/classes/config/schema.ts b/classes/config/schema.ts
index 8bb0ebda..b59d63a5 100644
--- a/classes/config/schema.ts
+++ b/classes/config/schema.ts
@@ -1,9 +1,9 @@
import { cwd } from "node:process";
-import { z } from "@hono/zod-openapi";
import { type BunFile, env, file } from "bun";
import ISO6391 from "iso-639-1";
import { types as mimeTypes } from "mime-types";
import { generateVAPIDKeys } from "web-push";
+import { z } from "zod";
import { ZodError } from "zod";
import { fromZodError } from "zod-validation-error";
import { RolePermission } from "~/packages/client/schemas/permissions.ts";
diff --git a/classes/database/application.ts b/classes/database/application.ts
index aea22137..1d692eae 100644
--- a/classes/database/application.ts
+++ b/classes/database/application.ts
@@ -1,4 +1,3 @@
-import type { z } from "@hono/zod-openapi";
import type {
Application as ApplicationSchema,
CredentialApplication,
@@ -13,6 +12,7 @@ import {
eq,
inArray,
} from "drizzle-orm";
+import type { z } from "zod";
import { BaseInterface } from "./base.ts";
type ApplicationType = InferSelectModel;
diff --git a/classes/database/emoji.ts b/classes/database/emoji.ts
index 0c66bd9c..0f7904c1 100644
--- a/classes/database/emoji.ts
+++ b/classes/database/emoji.ts
@@ -1,6 +1,5 @@
import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api";
import { proxyUrl } from "@/response";
-import type { z } from "@hono/zod-openapi";
import type { CustomEmoji } from "@versia/client/schemas";
import type { CustomEmojiExtension } from "@versia/federation/types";
import { type Instance, Media, db } from "@versia/kit/db";
@@ -15,6 +14,7 @@ import {
inArray,
isNull,
} from "drizzle-orm";
+import type { z } from "zod";
import { BaseInterface } from "./base.ts";
type EmojiType = InferSelectModel & {
diff --git a/classes/database/media.ts b/classes/database/media.ts
index 6b081988..bc998eef 100644
--- a/classes/database/media.ts
+++ b/classes/database/media.ts
@@ -1,7 +1,6 @@
import { join } from "node:path";
import { mimeLookup } from "@/content_types.ts";
import { proxyUrl } from "@/response";
-import type { z } from "@hono/zod-openapi";
import type { Attachment as AttachmentSchema } from "@versia/client/schemas";
import type { ContentFormat } from "@versia/federation/types";
import { db } from "@versia/kit/db";
@@ -16,6 +15,7 @@ import {
inArray,
} from "drizzle-orm";
import sharp from "sharp";
+import type { z } from "zod";
import { MediaBackendType } from "~/classes/config/schema.ts";
import { config } from "~/config.ts";
import { ApiError } from "../errors/api-error.ts";
diff --git a/classes/database/note.ts b/classes/database/note.ts
index c8303229..6a96439c 100644
--- a/classes/database/note.ts
+++ b/classes/database/note.ts
@@ -2,7 +2,6 @@ import { idValidator } from "@/api";
import { mergeAndDeduplicate } from "@/lib.ts";
import { sanitizedHtmlStrip } from "@/sanitization";
import { sentry } from "@/sentry";
-import type { z } from "@hono/zod-openapi";
import { getLogger } from "@logtape/logtape";
import type { Status, Status as StatusSchema } from "@versia/client/schemas";
import { EntityValidator } from "@versia/federation";
@@ -32,6 +31,7 @@ import {
} from "drizzle-orm";
import { htmlToText } from "html-to-text";
import { createRegExp, exactly, global } from "magic-regexp";
+import type { z } from "zod";
import {
contentToHtml,
findManyNotes,
diff --git a/classes/database/notification.ts b/classes/database/notification.ts
index 51601d3b..82359788 100644
--- a/classes/database/notification.ts
+++ b/classes/database/notification.ts
@@ -1,4 +1,3 @@
-import type { z } from "@hono/zod-openapi";
import type { Notification as NotificationSchema } from "@versia/client/schemas";
import { Note, User, db } from "@versia/kit/db";
import { Notifications } from "@versia/kit/tables";
@@ -10,6 +9,7 @@ import {
eq,
inArray,
} from "drizzle-orm";
+import type { z } from "zod";
import {
transformOutputToUserWithRelations,
userExtrasTemplate,
diff --git a/classes/database/pushsubscription.ts b/classes/database/pushsubscription.ts
index cb15f53f..9c804144 100644
--- a/classes/database/pushsubscription.ts
+++ b/classes/database/pushsubscription.ts
@@ -1,4 +1,3 @@
-import type { z } from "@hono/zod-openapi";
import type { WebPushSubscription as WebPushSubscriptionSchema } from "@versia/client/schemas";
import { type Token, type User, db } from "@versia/kit/db";
import { PushSubscriptions, Tokens } from "@versia/kit/tables";
@@ -10,6 +9,7 @@ import {
eq,
inArray,
} from "drizzle-orm";
+import type { z } from "zod";
import { BaseInterface } from "./base.ts";
type PushSubscriptionType = InferSelectModel;
diff --git a/classes/database/relationship.ts b/classes/database/relationship.ts
index 46e6a9cb..9c30a595 100644
--- a/classes/database/relationship.ts
+++ b/classes/database/relationship.ts
@@ -1,4 +1,3 @@
-import { z } from "@hono/zod-openapi";
import type { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { db } from "@versia/kit/db";
import { Relationships } from "@versia/kit/tables";
@@ -11,6 +10,7 @@ import {
eq,
inArray,
} from "drizzle-orm";
+import { z } from "zod";
import { BaseInterface } from "./base.ts";
import type { User } from "./user.ts";
diff --git a/classes/database/role.ts b/classes/database/role.ts
index 04ebf741..e2ce30cf 100644
--- a/classes/database/role.ts
+++ b/classes/database/role.ts
@@ -1,5 +1,4 @@
import { proxyUrl } from "@/response";
-import type { z } from "@hono/zod-openapi";
import type { Role as RoleSchema } from "@versia/client/schemas";
import type { RolePermission } from "@versia/client/schemas";
import { db } from "@versia/kit/db";
@@ -13,6 +12,7 @@ import {
eq,
inArray,
} from "drizzle-orm";
+import type { z } from "zod";
import { config } from "~/config.ts";
import { BaseInterface } from "./base.ts";
type RoleType = InferSelectModel;
diff --git a/classes/database/token.ts b/classes/database/token.ts
index 20d2f5d3..17197723 100644
--- a/classes/database/token.ts
+++ b/classes/database/token.ts
@@ -1,4 +1,3 @@
-import type { z } from "@hono/zod-openapi";
import type { Token as TokenSchema } from "@versia/client/schemas";
import { type Application, User, db } from "@versia/kit/db";
import { Tokens } from "@versia/kit/tables";
@@ -10,6 +9,7 @@ import {
eq,
inArray,
} from "drizzle-orm";
+import type { z } from "zod";
import { BaseInterface } from "./base.ts";
type TokenType = InferSelectModel & {
diff --git a/classes/database/user.ts b/classes/database/user.ts
index 9619b981..3607586a 100644
--- a/classes/database/user.ts
+++ b/classes/database/user.ts
@@ -3,7 +3,6 @@ import { getBestContentType } from "@/content_types";
import { randomString } from "@/math";
import { proxyUrl } from "@/response";
import { sentry } from "@/sentry";
-import type { z } from "@hono/zod-openapi";
import { getLogger } from "@logtape/logtape";
import type {
Account,
@@ -50,6 +49,7 @@ import {
sql,
} from "drizzle-orm";
import { htmlToText } from "html-to-text";
+import type { z } from "zod";
import { findManyUsers } from "~/classes/functions/user";
import { searchManager } from "~/classes/search/search-manager";
import { config } from "~/config.ts";
diff --git a/classes/errors/api-error.ts b/classes/errors/api-error.ts
index a34a971b..2fe76279 100644
--- a/classes/errors/api-error.ts
+++ b/classes/errors/api-error.ts
@@ -1,8 +1,8 @@
-// biome-ignore lint/correctness/noUndeclaredDependencies: Dependency of @hono/zod-openapi
-import type { ResponseConfig } from "@asteasolutions/zod-to-openapi";
-import { z } from "@hono/zod-openapi";
+import type { DescribeRouteOptions } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { JSONObject } from "hono/utils/types";
+import { z } from "zod";
/**
* API Error
@@ -33,12 +33,12 @@ export class ApiError extends Error {
.optional(),
});
- public get schema(): ResponseConfig {
+ public get schema(): DescribeRouteOptions["responses"] {
return {
description: this.message,
content: {
"application/json": {
- schema: ApiError.zodSchema,
+ schema: resolver(ApiError.zodSchema),
},
},
};
diff --git a/classes/plugin/loader.ts b/classes/plugin/loader.ts
index 0541e973..ed09e199 100644
--- a/classes/plugin/loader.ts
+++ b/classes/plugin/loader.ts
@@ -1,8 +1,8 @@
import { readdir } from "node:fs/promises";
-import type { OpenAPIHono } from "@hono/zod-openapi";
import { type Logger, getLogger } from "@logtape/logtape";
import chalk from "chalk";
import { parseJSON5, parseJSONC } from "confbox";
+import type { Hono } from "hono";
import type { ZodTypeAny } from "zod";
import { type ValidationError, fromZodError } from "zod-validation-error";
import { config } from "~/config.ts";
@@ -216,7 +216,7 @@ export class PluginLoader {
manifest: Manifest;
plugin: Plugin;
}[],
- app: OpenAPIHono,
+ app: Hono,
logger: Logger,
): Promise {
for (const data of plugins) {
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 07cbc0ca..6459134c 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -1,4 +1,3 @@
-import type { z } from "@hono/zod-openapi";
import type {
Notification as NotificationSchema,
Source,
@@ -20,6 +19,7 @@ import {
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
+import type { z } from "zod";
// biome-ignore lint/nursery/useExplicitType: Type is too complex
const createdAt = () =>
diff --git a/middlewares/rate-limit.ts b/middlewares/rate-limit.ts
index 7d211193..bddb1d72 100644
--- a/middlewares/rate-limit.ts
+++ b/middlewares/rate-limit.ts
@@ -1,7 +1,7 @@
-import type { z } from "@hono/zod-openapi";
import { env } from "bun";
import type { MiddlewareHandler } from "hono";
import { rateLimiter } from "hono-rate-limiter";
+import type { z } from "zod";
import type { ApiError } from "~/classes/errors/api-error";
import type { HonoEnv } from "~/types/api";
diff --git a/package.json b/package.json
index 2260882d..97c7ec78 100644
--- a/package.json
+++ b/package.json
@@ -85,7 +85,6 @@
"@clerc/plugin-version": "^0.44.0",
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@hono/prometheus": "^1.0.1",
- "@hono/zod-openapi": "0.19.2",
"@hono/zod-validator": "^0.4.3",
"@inquirer/confirm": "^5.1.8",
"@logtape/file": "^0.9.0",
@@ -104,6 +103,7 @@
"confbox": "^0.2.1",
"drizzle-orm": "^0.41.0",
"hono": "^4.7.5",
+ "hono-openapi": "^0.4.6",
"hono-rate-limiter": "^0.4.2",
"html-to-text": "^9.0.5",
"ioredis": "^5.6.0",
@@ -133,11 +133,9 @@
"xss": "^1.0.15",
"youch": "^4.1.0-beta.6",
"zod": "^3.24.2",
+ "zod-openapi": "^4.2.3",
"zod-validation-error": "^3.4.0"
},
- "overrides": {
- "zod": "^3.24.1"
- },
"patchedDependencies": {
"@bull-board/api@6.7.10": "patches/@bull-board%2Fapi@6.5.3.patch"
}
diff --git a/packages/client/package.json b/packages/client/package.json
index bd0602b3..966b02d8 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -55,7 +55,6 @@
"keywords": ["versia", "mastodon", "api", "typescript", "rest"],
"packageManager": "bun@1.2.5",
"dependencies": {
- "@badgateway/oauth2-client": "^2.4.2",
- "@hono/zod-openapi": "^0.19.2"
+ "@badgateway/oauth2-client": "^2.4.2"
}
}
diff --git a/packages/client/schemas/account-warning.ts b/packages/client/schemas/account-warning.ts
index 2f070eb1..1447b12c 100644
--- a/packages/client/schemas/account-warning.ts
+++ b/packages/client/schemas/account-warning.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Account } from "./account.ts";
import { Appeal } from "./appeal.ts";
import { Id } from "./common.ts";
@@ -49,9 +49,10 @@ export const AccountWarning = z
example: "2025-01-04T14:11:00Z",
}),
})
- .openapi("AccountWarning", {
+ .openapi({
description: "Moderation warning against a particular account.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/AccountWarning",
},
+ ref: "AccountWarning",
});
diff --git a/packages/client/schemas/account.ts b/packages/client/schemas/account.ts
index 9cda795a..890dc6e9 100644
--- a/packages/client/schemas/account.ts
+++ b/packages/client/schemas/account.ts
@@ -1,5 +1,5 @@
import { userAddressValidator } from "@/api.ts";
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { config } from "~/config.ts";
import { iso631, zBoolean } from "./common.ts";
import { CustomEmoji } from "./emoji.ts";
@@ -44,7 +44,7 @@ export const Field = z
},
}),
})
- .openapi("AccountField");
+ .openapi({ ref: "AccountField" });
export const Source = z
.object({
@@ -109,12 +109,13 @@ export const Source = z
description: "Metadata about the account.",
}),
})
- .openapi("AccountSource", {
+ .openapi({
description:
"An extra attribute that contains source values to be used with API methods that verify credentials and update credentials.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Account/#source",
},
+ ref: "AccountSource",
});
// Because Account has some recursive references, we need to define it like this
@@ -421,7 +422,7 @@ const BaseAccount = z
example: "2025-03-01T14:00:00.000Z",
}),
})
- .openapi("BaseAccount");
+ .openapi({ ref: "BaseAccount" });
export const Account = BaseAccount.extend({
moved: BaseAccount.nullable()
@@ -434,4 +435,4 @@ export const Account = BaseAccount.extend({
url: "https://docs.joinmastodon.org/entities/Account/#moved",
},
}),
-}).openapi("Account");
+}).openapi({ ref: "Account" });
diff --git a/packages/client/schemas/appeal.ts b/packages/client/schemas/appeal.ts
index caec9851..4b626d6e 100644
--- a/packages/client/schemas/appeal.ts
+++ b/packages/client/schemas/appeal.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
export const Appeal = z
.object({
@@ -13,9 +13,10 @@ export const Appeal = z
example: "pending",
}),
})
- .openapi("Appeal", {
+ .openapi({
description: "Appeal against a moderation action.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Appeal",
},
+ ref: "Appeal",
});
diff --git a/packages/client/schemas/application.ts b/packages/client/schemas/application.ts
index 784e1dbe..afda6a91 100644
--- a/packages/client/schemas/application.ts
+++ b/packages/client/schemas/application.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
export const Application = z
.object({
@@ -61,7 +61,9 @@ export const Application = z
},
}),
})
- .openapi("Application");
+ .openapi({
+ ref: "Application",
+ });
export const CredentialApplication = Application.extend({
client_id: z.string().openapi({
@@ -83,4 +85,6 @@ export const CredentialApplication = Application.extend({
url: "https://docs.joinmastodon.org/entities/CredentialApplication/#client_secret_expires_at",
},
}),
-}).openapi("CredentialApplication");
+}).openapi({
+ ref: "CredentialApplication",
+});
diff --git a/packages/client/schemas/attachment.ts b/packages/client/schemas/attachment.ts
index 5e500b66..238f1159 100644
--- a/packages/client/schemas/attachment.ts
+++ b/packages/client/schemas/attachment.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { config } from "~/config.ts";
import { Id } from "./common.ts";
@@ -67,10 +67,11 @@ export const Attachment = z
example: "UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}",
}),
})
- .openapi("Attachment", {
+ .openapi({
description:
"Represents a file or media attachment that can be added to a status.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Attachment",
},
+ ref: "Attachment",
});
diff --git a/packages/client/schemas/card.ts b/packages/client/schemas/card.ts
index 4bcbe171..3a7e804b 100644
--- a/packages/client/schemas/card.ts
+++ b/packages/client/schemas/card.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Account } from "./account.ts";
export const PreviewCardAuthor = z
@@ -27,7 +27,9 @@ export const PreviewCardAuthor = z
},
}),
})
- .openapi("PreviewCardAuthor");
+ .openapi({
+ ref: "PreviewCardAuthor",
+ });
export const PreviewCard = z
.object({
@@ -152,10 +154,11 @@ export const PreviewCard = z
},
}),
})
- .openapi("PreviewCard", {
+ .openapi({
description:
"Represents a rich preview card that is generated using OpenGraph tags from a URL.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/PreviewCard",
},
+ ref: "PreviewCard",
});
diff --git a/packages/client/schemas/common.ts b/packages/client/schemas/common.ts
index ceb54d56..621bb019 100644
--- a/packages/client/schemas/common.ts
+++ b/packages/client/schemas/common.ts
@@ -1,13 +1,21 @@
-import { z } from "@hono/zod-openapi";
import ISO6391 from "iso-639-1";
+import { z } from "zod";
export const Id = z.string().uuid();
export const iso631 = z
.enum(ISO6391.getAllCodes() as [string, ...string[]])
- .openapi("ISO631");
+ .openapi({
+ description: "ISO 639-1 language code",
+ example: "en",
+ externalDocs: {
+ url: "https://en.wikipedia.org/wiki/List_of_ISO_639-1_language_codes",
+ },
+ ref: "ISO631",
+ });
export const zBoolean = z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
+ .openapi({ type: "boolean" })
.or(z.boolean());
diff --git a/packages/client/schemas/context.ts b/packages/client/schemas/context.ts
index 16019f4d..5916dd14 100644
--- a/packages/client/schemas/context.ts
+++ b/packages/client/schemas/context.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Status } from "./status.ts";
export const Context = z
@@ -16,10 +16,11 @@ export const Context = z
},
}),
})
- .openapi("Context", {
+ .openapi({
description:
"Represents the tree around a given status. Used for reconstructing threads of statuses.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Context/#context",
},
+ ref: "Context",
});
diff --git a/packages/client/schemas/emoji.ts b/packages/client/schemas/emoji.ts
index 1ea67570..af93f73e 100644
--- a/packages/client/schemas/emoji.ts
+++ b/packages/client/schemas/emoji.ts
@@ -1,5 +1,5 @@
import { emojiValidator } from "@/api.ts";
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { config } from "~/config.ts";
import { Id, zBoolean } from "./common.ts";
@@ -87,9 +87,10 @@ export const CustomEmoji = z
},
}),
})
- .openapi("CustomEmoji", {
+ .openapi({
description: "Represents a custom emoji.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/CustomEmoji",
},
+ ref: "CustomEmoji",
});
diff --git a/packages/client/schemas/extended-description.ts b/packages/client/schemas/extended-description.ts
index 87087475..a45c808e 100644
--- a/packages/client/schemas/extended-description.ts
+++ b/packages/client/schemas/extended-description.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
export const ExtendedDescription = z
.object({
@@ -22,10 +22,11 @@ export const ExtendedDescription = z
},
}),
})
- .openapi("ExtendedDescription", {
+ .openapi({
description:
"Represents an extended description for the instance, to be shown on its about page.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/ExtendedDescription",
},
+ ref: "ExtendedDescription",
});
diff --git a/packages/client/schemas/familiar-followers.ts b/packages/client/schemas/familiar-followers.ts
index 6e8e442e..cfeeee15 100644
--- a/packages/client/schemas/familiar-followers.ts
+++ b/packages/client/schemas/familiar-followers.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Account } from "./account.ts";
export const FamiliarFollowers = z
@@ -17,10 +17,11 @@ export const FamiliarFollowers = z
},
}),
})
- .openapi("FamiliarFollowers", {
+ .openapi({
description:
"Represents a subset of your follows who also follow some other user.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/FamiliarFollowers",
},
+ ref: "FamiliarFollowers",
});
diff --git a/packages/client/schemas/filters.ts b/packages/client/schemas/filters.ts
index 75055d27..6e92ceb1 100644
--- a/packages/client/schemas/filters.ts
+++ b/packages/client/schemas/filters.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Id, zBoolean } from "./common.ts";
export const FilterStatus = z
@@ -18,12 +18,13 @@ export const FilterStatus = z
},
}),
})
- .openapi("FilterStatus", {
+ .openapi({
description:
"Represents a status ID that, if matched, should cause the filter action to be taken.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/FilterStatus",
},
+ ref: "FilterStatus",
});
export const FilterKeyword = z
@@ -51,12 +52,13 @@ export const FilterKeyword = z
},
}),
})
- .openapi("FilterKeyword", {
+ .openapi({
description:
"Represents a keyword that, if matched, should cause the filter action to be taken.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/FilterKeyword",
},
+ ref: "FilterKeyword",
});
export const Filter = z
@@ -133,12 +135,13 @@ export const Filter = z
},
}),
})
- .openapi("Filter", {
+ .openapi({
description:
"Represents a user-defined filter for determining which statuses should not be shown to the user.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Filter",
},
+ ref: "Filter",
});
export const FilterResult = z
@@ -171,10 +174,11 @@ export const FilterResult = z
},
}),
})
- .openapi("FilterResult", {
+ .openapi({
description:
"Represents a filter whose keywords matched a given status.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/FilterResult",
},
+ ref: "FilterResult",
});
diff --git a/packages/client/schemas/instance-v1.ts b/packages/client/schemas/instance-v1.ts
index d00008e4..75e058df 100644
--- a/packages/client/schemas/instance-v1.ts
+++ b/packages/client/schemas/instance-v1.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Instance } from "./instance.ts";
import { SSOConfig } from "./versia.ts";
@@ -132,10 +132,11 @@ export const InstanceV1 = z
/* Versia Server API extension */
sso: SSOConfig,
})
- .openapi("InstanceV1", {
+ .openapi({
description:
"Represents the software instance of Versia Server running on this domain.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/V1_Instance",
},
+ ref: "InstanceV1",
});
diff --git a/packages/client/schemas/instance.ts b/packages/client/schemas/instance.ts
index 2e688524..d5ad2c02 100644
--- a/packages/client/schemas/instance.ts
+++ b/packages/client/schemas/instance.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import pkg from "~/package.json";
import { Account } from "./account.ts";
import { iso631 } from "./common.ts";
@@ -18,10 +18,11 @@ const InstanceIcon = z
example: "36x36",
}),
})
- .openapi("InstanceIcon", {
+ .openapi({
externalDocs: {
url: "https://docs.joinmastodon.org/entities/InstanceIcon",
},
+ ref: "InstanceIcon",
});
export const Instance = z
@@ -375,8 +376,9 @@ export const Instance = z
/* Versia Server API extension */
sso: SSOConfig,
})
- .openapi("Instance", {
+ .openapi({
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Instance",
},
+ ref: "Instance",
});
diff --git a/packages/client/schemas/marker.ts b/packages/client/schemas/marker.ts
index b6fb269c..74fe122e 100644
--- a/packages/client/schemas/marker.ts
+++ b/packages/client/schemas/marker.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Id } from "./common.ts";
export const Marker = z
@@ -17,10 +17,11 @@ export const Marker = z
example: "2025-01-12T13:11:00Z",
}),
})
- .openapi("Marker", {
+ .openapi({
description:
"Represents the last read position within a user's timelines.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Marker",
},
+ ref: "Marker",
});
diff --git a/packages/client/schemas/notification.ts b/packages/client/schemas/notification.ts
index e82e466d..592e16c5 100644
--- a/packages/client/schemas/notification.ts
+++ b/packages/client/schemas/notification.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { AccountWarning } from "./account-warning.ts";
import { Account } from "./account.ts";
import { Id } from "./common.ts";
@@ -62,10 +62,11 @@ export const Notification = z
"Moderation warning that caused the notification. Attached when type of the notification is moderation_warning.",
}),
})
- .openapi("Notification", {
+ .openapi({
description:
"Represents a notification of an event relevant to the user.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Notification",
},
+ ref: "Notification",
});
diff --git a/packages/client/schemas/poll.ts b/packages/client/schemas/poll.ts
index e4d126fd..45507fd0 100644
--- a/packages/client/schemas/poll.ts
+++ b/packages/client/schemas/poll.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { config } from "~/config.ts";
import { Id } from "./common.ts";
import { CustomEmoji } from "./emoji.ts";
@@ -31,10 +31,11 @@ export const PollOption = z
},
}),
})
- .openapi("PollOption", {
+ .openapi({
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Poll/#Option",
},
+ ref: "PollOption",
});
export const Poll = z
@@ -130,9 +131,10 @@ export const Poll = z
},
}),
})
- .openapi("Poll", {
+ .openapi({
description: "Represents a poll attached to a status.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Poll",
},
+ ref: "Poll",
});
diff --git a/packages/client/schemas/preferences.ts b/packages/client/schemas/preferences.ts
index e4c999f1..34fb609f 100644
--- a/packages/client/schemas/preferences.ts
+++ b/packages/client/schemas/preferences.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Source } from "./account.ts";
export const Preferences = z
@@ -42,9 +42,10 @@ export const Preferences = z
},
}),
})
- .openapi("Preferences", {
+ .openapi({
description: "Represents a user's preferences.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Preferences",
},
+ ref: "Preferences",
});
diff --git a/packages/client/schemas/privacy-policy.ts b/packages/client/schemas/privacy-policy.ts
index ffa92021..c057249f 100644
--- a/packages/client/schemas/privacy-policy.ts
+++ b/packages/client/schemas/privacy-policy.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
export const PrivacyPolicy = z
.object({
@@ -21,9 +21,10 @@ export const PrivacyPolicy = z
},
}),
})
- .openapi("PrivacyPolicy", {
+ .openapi({
description: "Represents the privacy policy of the instance.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/PrivacyPolicy",
},
+ ref: "PrivacyPolicy",
});
diff --git a/packages/client/schemas/pushsubscription.ts b/packages/client/schemas/pushsubscription.ts
index 62cc35bc..5236ea7c 100644
--- a/packages/client/schemas/pushsubscription.ts
+++ b/packages/client/schemas/pushsubscription.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Id } from "./common.ts";
export const WebPushSubscription = z
@@ -80,7 +80,7 @@ export const WebPushSubscription = z
description: "The streaming server’s VAPID key.",
}),
})
- .openapi("WebPushSubscription");
+ .openapi({ ref: "WebPushSubscription" });
export const WebPushSubscriptionInput = z
.object({
diff --git a/packages/client/schemas/relationship.ts b/packages/client/schemas/relationship.ts
index fa8486c0..f24c7cbe 100644
--- a/packages/client/schemas/relationship.ts
+++ b/packages/client/schemas/relationship.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Id, iso631 } from "./common.ts";
export const Relationship = z
@@ -65,10 +65,11 @@ export const Relationship = z
example: "they also like Kerbal Space Program",
}),
})
- .openapi("Relationship", {
+ .openapi({
description:
"Represents the relationship between accounts, such as following / blocking / muting / etc.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Relationship",
},
+ ref: "Relationship",
});
diff --git a/packages/client/schemas/report.ts b/packages/client/schemas/report.ts
index 3ab7b1e2..adef106f 100644
--- a/packages/client/schemas/report.ts
+++ b/packages/client/schemas/report.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Account } from "./account.ts";
import { Id } from "./common.ts";
@@ -50,10 +50,11 @@ export const Report = z
description: "The account that was reported.",
}),
})
- .openapi("Report", {
+ .openapi({
description:
"Reports filed against users and/or statuses, to be taken action on by moderators.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Report",
},
+ ref: "Report",
});
diff --git a/packages/client/schemas/rule.ts b/packages/client/schemas/rule.ts
index 030c0822..692a30a7 100644
--- a/packages/client/schemas/rule.ts
+++ b/packages/client/schemas/rule.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
export const Rule = z
.object({
@@ -15,9 +15,10 @@ export const Rule = z
example: "Please, we beg you.",
}),
})
- .openapi("Rule", {
+ .openapi({
description: "Represents a rule that server users should follow.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Rule",
},
+ ref: "Rule",
});
diff --git a/packages/client/schemas/search.ts b/packages/client/schemas/search.ts
index f9e4200d..46bd9664 100644
--- a/packages/client/schemas/search.ts
+++ b/packages/client/schemas/search.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Account } from "./account.ts";
import { Status } from "./status.ts";
import { Tag } from "./tag.ts";
@@ -15,9 +15,10 @@ export const Search = z
description: "Hashtags which match the given query",
}),
})
- .openapi("Search", {
+ .openapi({
description: "Represents the results of a search.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Search",
},
+ ref: "Search",
});
diff --git a/packages/client/schemas/status.ts b/packages/client/schemas/status.ts
index 0ac6575d..28376953 100644
--- a/packages/client/schemas/status.ts
+++ b/packages/client/schemas/status.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { config } from "~/config.ts";
import { Account } from "./account.ts";
import { Attachment } from "./attachment.ts";
@@ -42,10 +42,11 @@ export const Mention = z
},
}),
})
- .openapi("Mention", {
+ .openapi({
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Status/#Mention",
},
+ ref: "Mention",
});
export const StatusSource = z
@@ -75,10 +76,11 @@ export const StatusSource = z
example: "",
}),
})
- .openapi("StatusSource", {
+ .openapi({
externalDocs: {
url: "https://docs.joinmastodon.org/entities/StatusSource",
},
+ ref: "StatusSource",
});
// Because Status has some recursive references, we need to define it like this
@@ -356,7 +358,9 @@ const BaseStatus = z
},
}),
})
- .openapi("BaseStatus");
+ .openapi({
+ ref: "BaseStatus",
+ });
export const Status = BaseStatus.extend({
reblog: BaseStatus.nullable().openapi({
@@ -366,7 +370,9 @@ export const Status = BaseStatus.extend({
},
}),
quote: BaseStatus.nullable(),
-}).openapi("Status");
+}).openapi({
+ ref: "Status",
+});
export const ScheduledStatus = z
.object({
@@ -414,4 +420,6 @@ export const ScheduledStatus = z
}),
}),
})
- .openapi("ScheduledStatus");
+ .openapi({
+ ref: "ScheduledStatus",
+ });
diff --git a/packages/client/schemas/tag.ts b/packages/client/schemas/tag.ts
index 8255fb65..b6cd1eba 100644
--- a/packages/client/schemas/tag.ts
+++ b/packages/client/schemas/tag.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
export const Tag = z
.object({
@@ -24,8 +24,9 @@ export const Tag = z
},
}),
})
- .openapi("Tag", {
+ .openapi({
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Status/#Tag",
},
+ ref: "Tag",
});
diff --git a/packages/client/schemas/token.ts b/packages/client/schemas/token.ts
index 4d718f15..cc83657d 100644
--- a/packages/client/schemas/token.ts
+++ b/packages/client/schemas/token.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
export const Token = z
.object({
@@ -20,10 +20,11 @@ export const Token = z
example: 1573979017,
}),
})
- .openapi("Token", {
+ .openapi({
description:
"Represents an OAuth token used for authenticating with the API and performing actions.",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Token",
},
+ ref: "Token",
});
diff --git a/packages/client/schemas/tos.ts b/packages/client/schemas/tos.ts
index f8172b98..bf47ed98 100644
--- a/packages/client/schemas/tos.ts
+++ b/packages/client/schemas/tos.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
export const TermsOfService = z
.object({
@@ -11,6 +11,7 @@ export const TermsOfService = z
example: "
ToS None, have fun.
",
}),
})
- .openapi("TermsOfService", {
+ .openapi({
description: "Represents the ToS of the instance.",
+ ref: "TermsOfService",
});
diff --git a/packages/client/schemas/versia.ts b/packages/client/schemas/versia.ts
index 9aeb1624..5359dd0b 100644
--- a/packages/client/schemas/versia.ts
+++ b/packages/client/schemas/versia.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { config } from "~/config.ts";
import { Id } from "./common.ts";
import { RolePermission } from "./permissions.ts";
@@ -6,7 +6,7 @@ import { RolePermission } from "./permissions.ts";
/* Versia Server API extension */
export const Role = z
.object({
- id: Id.openapi({}).openapi({
+ id: Id.openapi({
description: "The role ID in the database.",
example: "b4a7e0f0-8f6a-479b-910b-9265c070d5bd",
}),
@@ -27,6 +27,7 @@ export const Role = z
RolePermission.ManageEmojis,
RolePermission.ManageAccounts,
],
+ type: "array",
}),
priority: z.number().int().default(0).openapi({
description:
@@ -45,9 +46,10 @@ export const Role = z
example: "https://example.com/role-icon.png",
}),
})
- .openapi("Role", {
+ .openapi({
description:
"Information about a role in the system, as well as its permissions.",
+ ref: "Role",
});
/* Versia Server API extension */
@@ -72,8 +74,9 @@ export const NoteReaction = z
example: true,
}),
})
- .openapi("NoteReaction", {
+ .openapi({
description: "Information about a reaction to a note.",
+ ref: "NoteReaction",
});
/* Versia Server API extension */
@@ -134,6 +137,7 @@ export const Challenge = z
example: "1234567890",
}),
})
- .openapi("Challenge", {
+ .openapi({
description: "A cryptographic challenge to solve. Used for Captchas.",
+ ref: "Challenge",
});
diff --git a/packages/client/versia/client.ts b/packages/client/versia/client.ts
index 61d54e32..78be975f 100644
--- a/packages/client/versia/client.ts
+++ b/packages/client/versia/client.ts
@@ -1142,10 +1142,8 @@ export class Client extends BaseClient {
): Promise[]>> {
const params = new URLSearchParams();
- if (ids) {
- for (const id of ids) {
- params.append("id[]", id);
- }
+ for (const id of ids) {
+ params.append("id[]", id);
}
return this.get[]>(
diff --git a/packages/plugin-kit/example.ts b/packages/plugin-kit/example.ts
index 92c01b00..8ebb14cf 100644
--- a/packages/plugin-kit/example.ts
+++ b/packages/plugin-kit/example.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
import { Hooks } from "./hooks.ts";
import { Plugin } from "./plugin.ts";
diff --git a/packages/plugin-kit/plugin.ts b/packages/plugin-kit/plugin.ts
index f685600c..7b4cf035 100644
--- a/packages/plugin-kit/plugin.ts
+++ b/packages/plugin-kit/plugin.ts
@@ -1,5 +1,4 @@
-import type { OpenAPIHono } from "@hono/zod-openapi";
-import type { MiddlewareHandler } from "hono";
+import type { Hono, MiddlewareHandler } from "hono";
import { createMiddleware } from "hono/factory";
import type { z } from "zod";
import { type ZodError, fromZodError } from "zod-validation-error";
@@ -17,7 +16,7 @@ export class Plugin {
private store: z.infer | null = null;
private routes: {
path: string;
- fn: (app: OpenAPIHono>) => void;
+ fn: (app: Hono>) => void;
}[] = [];
public constructor(private configSchema: ConfigSchema) {}
@@ -34,7 +33,7 @@ export class Plugin {
public registerRoute(
path: string,
- fn: (app: OpenAPIHono>) => void,
+ fn: (app: Hono>) => void,
): void {
this.routes.push({
path,
@@ -55,12 +54,10 @@ export class Plugin {
}
}
- protected _addToApp(app: OpenAPIHono): void {
+ protected _addToApp(app: Hono): void {
for (const route of this.routes) {
app.use(route.path, this.middleware);
- route.fn(
- app as unknown as OpenAPIHono>,
- );
+ route.fn(app as unknown as Hono>);
}
}
diff --git a/packages/plugin-kit/schema.ts b/packages/plugin-kit/schema.ts
index 7c04abad..97288af3 100644
--- a/packages/plugin-kit/schema.ts
+++ b/packages/plugin-kit/schema.ts
@@ -1,4 +1,4 @@
-import { z } from "@hono/zod-openapi";
+import { z } from "zod";
export const manifestSchema = z.object({
// biome-ignore lint/style/useNamingConvention:
diff --git a/plugins/openid/index.ts b/plugins/openid/index.ts
index 504c188d..4ec0d565 100644
--- a/plugins/openid/index.ts
+++ b/plugins/openid/index.ts
@@ -1,10 +1,10 @@
-import { z } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas";
import { Hooks, Plugin } from "@versia/kit";
import { User } from "@versia/kit/db";
import { getCookie } from "hono/cookie";
import { jwtVerify } from "jose";
import { JOSEError, JWTExpired } from "jose/errors";
+import { z } from "zod";
import { keyPair, sensitiveString } from "~/classes/config/schema.ts";
import { ApiError } from "~/classes/errors/api-error.ts";
import authorizeRoute from "./routes/authorize.ts";
diff --git a/plugins/openid/routes/authorize.ts b/plugins/openid/routes/authorize.ts
index cf0a365a..a0e9706c 100644
--- a/plugins/openid/routes/authorize.ts
+++ b/plugins/openid/routes/authorize.ts
@@ -1,105 +1,101 @@
-import { auth, jsonOrForm } from "@/api";
+import { auth, handleZodError, jsonOrForm } from "@/api";
import { randomString } from "@/math";
-import { z } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas";
import { Application, Token, User } from "@versia/kit/db";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
import { type JWTPayload, SignJWT, jwtVerify } from "jose";
import { JOSEError } from "jose/errors";
+import { z } from "zod";
import { errorRedirect, errors } from "../errors.ts";
import type { PluginType } from "../index.ts";
-const schemas = {
- query: z.object({
- prompt: z
- .enum(["none", "login", "consent", "select_account"])
- .optional()
- .default("none"),
- max_age: z.coerce
- .number()
- .int()
- .optional()
- .default(60 * 60 * 24 * 7),
- }),
- json: z
- .object({
- scope: z.string().optional(),
- redirect_uri: z
- .string()
- .url()
- .optional()
- .or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
- response_type: z.enum([
- "code",
- "token",
- "none",
- "id_token",
- "code id_token",
- "code token",
- "token id_token",
- "code token id_token",
- ]),
- client_id: z.string(),
- state: z.string().optional(),
- code_challenge: z.string().optional(),
- code_challenge_method: z.enum(["plain", "S256"]).optional(),
- })
- .refine(
- // Check if redirect_uri is valid for code flow
- (data) =>
- data.response_type.includes("code") ? data.redirect_uri : true,
- "redirect_uri is required for code flow",
- ),
- // Disable for Mastodon API compatibility
- /* .refine(
- // Check if code_challenge is valid for code flow
- (data) =>
- data.response_type.includes("code")
- ? data.code_challenge
- : true,
- "code_challenge is required for code flow",
- ), */
- cookies: z.object({
- jwt: z.string(),
- }),
-};
-
export default (plugin: PluginType): void =>
plugin.registerRoute("/oauth/authorize", (app) =>
- app.openapi(
- {
- method: "post",
- path: "/oauth/authorize",
+ app.post(
+ "/oauth/authorize",
+ describeRoute({
+ summary: "Main OpenID authorization endpoint",
tags: ["OpenID"],
- middleware: [
- auth({
- auth: false,
- }),
- jsonOrForm(),
- plugin.middleware,
- ] as const,
responses: {
302: {
description: "Redirect to the application",
},
},
- request: {
- query: schemas.query,
- body: {
- content: {
- "application/json": {
- schema: schemas.json,
- },
- "application/x-www-form-urlencoded": {
- schema: schemas.json,
- },
- "multipart/form-data": {
- schema: schemas.json,
- },
- },
- },
- cookies: schemas.cookies,
- },
- },
+ }),
+ plugin.middleware,
+ auth({
+ auth: false,
+ }),
+ jsonOrForm(),
+ validator(
+ "query",
+ z.object({
+ prompt: z
+ .enum(["none", "login", "consent", "select_account"])
+ .optional()
+ .default("none"),
+ max_age: z.coerce
+ .number()
+ .int()
+ .optional()
+ .default(60 * 60 * 24 * 7),
+ }),
+ handleZodError,
+ ),
+ validator(
+ "json",
+ z
+ .object({
+ scope: z.string().optional(),
+ redirect_uri: z
+ .string()
+ .url()
+ .optional()
+ .or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
+ response_type: z.enum([
+ "code",
+ "token",
+ "none",
+ "id_token",
+ "code id_token",
+ "code token",
+ "token id_token",
+ "code token id_token",
+ ]),
+ client_id: z.string(),
+ state: z.string().optional(),
+ code_challenge: z.string().optional(),
+ code_challenge_method: z
+ .enum(["plain", "S256"])
+ .optional(),
+ })
+ .refine(
+ // Check if redirect_uri is valid for code flow
+ (data) =>
+ data.response_type.includes("code")
+ ? data.redirect_uri
+ : true,
+ "redirect_uri is required for code flow",
+ ),
+ // Disable for Mastodon API compatibility
+ /* .refine(
+ // Check if code_challenge is valid for code flow
+ (data) =>
+ data.response_type.includes("code")
+ ? data.code_challenge
+ : true,
+ "code_challenge is required for code flow",
+ ), */
+ handleZodError,
+ ),
+ validator(
+ "cookie",
+ z.object({
+ jwt: z.string(),
+ }),
+ handleZodError,
+ ),
async (context) => {
const { scope, redirect_uri, client_id, state } =
context.req.valid("json");
diff --git a/plugins/openid/routes/jwks.ts b/plugins/openid/routes/jwks.ts
index 59e71bf0..38fdb02e 100644
--- a/plugins/openid/routes/jwks.ts
+++ b/plugins/openid/routes/jwks.ts
@@ -1,14 +1,15 @@
import { auth } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { describeRoute } from "hono-openapi";
+import { resolver } from "hono-openapi/zod";
import { exportJWK } from "jose";
+import { z } from "zod";
import type { PluginType } from "../index.ts";
export default (plugin: PluginType): void => {
plugin.registerRoute("/.well-known/jwks", (app) =>
- app.openapi(
- createRoute({
- method: "get",
- path: "/.well-known/jwks",
+ app.get(
+ "/.well-known/jwks",
+ describeRoute({
summary: "JWK Set",
tags: ["OpenID"],
responses: {
@@ -16,30 +17,30 @@ export default (plugin: PluginType): void => {
description: "JWK Set",
content: {
"application/json": {
- schema: z.object({
- keys: z.array(
- z.object({
- kty: z.string().optional(),
- use: z.string(),
- alg: z.string(),
- kid: z.string(),
- crv: z.string().optional(),
- x: z.string().optional(),
- y: z.string().optional(),
- }),
- ),
- }),
+ schema: resolver(
+ z.object({
+ keys: z.array(
+ z.object({
+ kty: z.string().optional(),
+ use: z.string(),
+ alg: z.string(),
+ kid: z.string(),
+ crv: z.string().optional(),
+ x: z.string().optional(),
+ y: z.string().optional(),
+ }),
+ ),
+ }),
+ ),
},
},
},
},
- middleware: [
- auth({
- auth: false,
- }),
- plugin.middleware,
- ] as const,
}),
+ auth({
+ auth: false,
+ }),
+ plugin.middleware,
async (context) => {
const jwk = await exportJWK(
context.get("pluginConfig").keys?.public,
diff --git a/plugins/openid/routes/oauth/callback.ts b/plugins/openid/routes/oauth/callback.ts
index c8c1b1ce..929047df 100644
--- a/plugins/openid/routes/oauth/callback.ts
+++ b/plugins/openid/routes/oauth/callback.ts
@@ -1,46 +1,28 @@
+import { handleZodError } from "@/api";
import { randomString } from "@/math.ts";
-import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media, Token, User, db } from "@versia/kit/db";
import { type SQL, and, eq, isNull } from "@versia/kit/drizzle";
import { OpenIdAccounts, Users } from "@versia/kit/tables";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
import { setCookie } from "hono/cookie";
import { SignJWT } from "jose";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error.ts";
import type { PluginType } from "../../index.ts";
import { automaticOidcFlow } from "../../utils.ts";
-const schemas = {
- query: z.object({
- client_id: z.string().optional(),
- flow: z.string(),
- link: z
- .string()
- .transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
- .optional(),
- user_id: z.string().uuid().optional(),
- }),
- param: z.object({
- issuer: z.string(),
- }),
-};
-
export default (plugin: PluginType): void => {
plugin.registerRoute("/oauth/sso/{issuer}/callback", (app) => {
- app.openapi(
- createRoute({
- method: "get",
- path: "/oauth/sso/{issuer}/callback",
+ app.get(
+ "/oauth/sso/:issuer/callback",
+ describeRoute({
summary: "SSO callback",
tags: ["OpenID"],
description:
"After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code",
- middleware: [plugin.middleware] as const,
- request: {
- query: schemas.query,
- params: schemas.param,
- },
responses: {
302: {
description:
@@ -48,6 +30,29 @@ export default (plugin: PluginType): void => {
},
},
}),
+ plugin.middleware,
+ validator(
+ "param",
+ z.object({
+ issuer: z.string(),
+ }),
+ handleZodError,
+ ),
+ validator(
+ "query",
+ z.object({
+ client_id: z.string().optional(),
+ flow: z.string(),
+ link: z
+ .string()
+ .transform((v) =>
+ ["true", "1", "on"].includes(v.toLowerCase()),
+ )
+ .optional(),
+ user_id: z.string().uuid().optional(),
+ }),
+ handleZodError,
+ ),
async (context) => {
const currentUrl = new URL(context.req.url);
const redirectUrl = new URL(context.req.url);
diff --git a/plugins/openid/routes/oauth/revoke.ts b/plugins/openid/routes/oauth/revoke.ts
index 17710803..96dd99fb 100644
--- a/plugins/openid/routes/oauth/revoke.ts
+++ b/plugins/openid/routes/oauth/revoke.ts
@@ -1,48 +1,25 @@
-import { jsonOrForm } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { handleZodError, jsonOrForm } from "@/api";
import { Token, db } from "@versia/kit/db";
import { and, eq } from "@versia/kit/drizzle";
import { Tokens } from "@versia/kit/tables";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import type { PluginType } from "../../index.ts";
-const schemas = {
- json: z.object({
- client_id: z.string(),
- client_secret: z.string(),
- token: z.string().optional(),
- }),
-};
-
export default (plugin: PluginType): void => {
plugin.registerRoute("/oauth/revoke", (app) => {
- app.openapi(
- createRoute({
- method: "post",
- path: "/oauth/revoke",
+ app.post(
+ "/oauth/revoke",
+ describeRoute({
summary: "Revoke token",
tags: ["OpenID"],
- middleware: [jsonOrForm(), plugin.middleware],
- request: {
- body: {
- content: {
- "application/json": {
- schema: schemas.json,
- },
- "application/x-www-form-urlencoded": {
- schema: schemas.json,
- },
- "multipart/form-data": {
- schema: schemas.json,
- },
- },
- },
- },
responses: {
200: {
description: "Token deleted",
content: {
"application/json": {
- schema: z.object({}),
+ schema: resolver(z.object({})),
},
},
},
@@ -50,15 +27,28 @@ export default (plugin: PluginType): void => {
description: "Authorization error",
content: {
"application/json": {
- schema: z.object({
- error: z.string(),
- error_description: z.string(),
- }),
+ schema: resolver(
+ z.object({
+ error: z.string(),
+ error_description: z.string(),
+ }),
+ ),
},
},
},
},
}),
+ jsonOrForm(),
+ plugin.middleware,
+ validator(
+ "json",
+ z.object({
+ client_id: z.string(),
+ client_secret: z.string(),
+ token: z.string().optional(),
+ }),
+ handleZodError,
+ ),
async (context) => {
const { client_id, client_secret, token } =
context.req.valid("json");
diff --git a/plugins/openid/routes/oauth/sso.ts b/plugins/openid/routes/oauth/sso.ts
index bbbf4582..a18445ad 100644
--- a/plugins/openid/routes/oauth/sso.ts
+++ b/plugins/openid/routes/oauth/sso.ts
@@ -1,37 +1,25 @@
-import { createRoute, z } from "@hono/zod-openapi";
+import { handleZodError } from "@/api.ts";
import { Application, db } from "@versia/kit/db";
import { OpenIdLoginFlows } from "@versia/kit/tables";
+import { describeRoute } from "hono-openapi";
+import { validator } from "hono-openapi/zod";
import {
calculatePKCECodeChallenge,
discoveryRequest,
generateRandomCodeVerifier,
processDiscoveryResponse,
} from "oauth4webapi";
+import { z } from "zod";
import type { PluginType } from "../../index.ts";
import { oauthRedirectUri } from "../../utils.ts";
-const schemas = {
- query: z.object({
- issuer: z.string(),
- client_id: z.string().optional(),
- redirect_uri: z.string().url().optional(),
- scope: z.string().optional(),
- response_type: z.enum(["code"]).optional(),
- }),
-};
-
export default (plugin: PluginType): void => {
plugin.registerRoute("/oauth/sso", (app) => {
- app.openapi(
- createRoute({
- method: "get",
- path: "/oauth/sso",
+ app.get(
+ "/oauth/sso",
+ describeRoute({
summary: "Initiate SSO login flow",
tags: ["OpenID"],
- request: {
- query: schemas.query,
- },
- middleware: [plugin.middleware] as const,
responses: {
302: {
description:
@@ -39,6 +27,18 @@ export default (plugin: PluginType): void => {
},
},
}),
+ plugin.middleware,
+ validator(
+ "query",
+ z.object({
+ issuer: z.string(),
+ client_id: z.string().optional(),
+ redirect_uri: z.string().url().optional(),
+ scope: z.string().optional(),
+ response_type: z.enum(["code"]).optional(),
+ }),
+ handleZodError,
+ ),
async (context) => {
// This is the Versia client's client_id, not the external OAuth provider's client_id
const { issuer: issuerId, client_id } =
diff --git a/plugins/openid/routes/oauth/token.ts b/plugins/openid/routes/oauth/token.ts
index d4a64d88..aaaaea18 100644
--- a/plugins/openid/routes/oauth/token.ts
+++ b/plugins/openid/routes/oauth/token.ts
@@ -1,87 +1,44 @@
-import { jsonOrForm } from "@/api";
-import { createRoute, z } from "@hono/zod-openapi";
+import { handleZodError, jsonOrForm } from "@/api";
import { Application, Token } from "@versia/kit/db";
import { and, eq } from "@versia/kit/drizzle";
import { Tokens } from "@versia/kit/tables";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import type { PluginType } from "../../index.ts";
-const schemas = {
- json: z.object({
- code: z.string().optional(),
- code_verifier: z.string().optional(),
- grant_type: z
- .enum([
- "authorization_code",
- "refresh_token",
- "client_credentials",
- "password",
- "urn:ietf:params:oauth:grant-type:device_code",
- "urn:ietf:params:oauth:grant-type:token-exchange",
- "urn:ietf:params:oauth:grant-type:saml2-bearer",
- "urn:openid:params:grant-type:ciba",
- ])
- .default("authorization_code"),
- client_id: z.string().optional(),
- client_secret: z.string().optional(),
- username: z.string().trim().optional(),
- password: z.string().trim().optional(),
- redirect_uri: z.string().url().optional(),
- refresh_token: z.string().optional(),
- scope: z.string().optional(),
- assertion: z.string().optional(),
- audience: z.string().optional(),
- subject_token_type: z.string().optional(),
- subject_token: z.string().optional(),
- actor_token_type: z.string().optional(),
- actor_token: z.string().optional(),
- auth_req_id: z.string().optional(),
- }),
-};
-
export default (plugin: PluginType): void => {
plugin.registerRoute("/oauth/token", (app) => {
- app.openapi(
- createRoute({
- method: "post",
- path: "/oauth/token",
+ app.post(
+ "/oauth/token",
+ describeRoute({
summary: "Get token",
tags: ["OpenID"],
- middleware: [jsonOrForm(), plugin.middleware],
- request: {
- body: {
- content: {
- "application/json": {
- schema: schemas.json,
- },
- "application/x-www-form-urlencoded": {
- schema: schemas.json,
- },
- "multipart/form-data": {
- schema: schemas.json,
- },
- },
- },
- },
responses: {
200: {
description: "Token",
content: {
"application/json": {
- schema: z.object({
- access_token: z.string(),
- token_type: z.string(),
- expires_in: z
- .number()
- .optional()
- .nullable(),
- id_token: z.string().optional().nullable(),
- refresh_token: z
- .string()
- .optional()
- .nullable(),
- scope: z.string().optional(),
- created_at: z.number(),
- }),
+ schema: resolver(
+ z.object({
+ access_token: z.string(),
+ token_type: z.string(),
+ expires_in: z
+ .number()
+ .optional()
+ .nullable(),
+ id_token: z
+ .string()
+ .optional()
+ .nullable(),
+ refresh_token: z
+ .string()
+ .optional()
+ .nullable(),
+ scope: z.string().optional(),
+ created_at: z.number(),
+ }),
+ ),
},
},
},
@@ -89,15 +46,53 @@ export default (plugin: PluginType): void => {
description: "Authorization error",
content: {
"application/json": {
- schema: z.object({
- error: z.string(),
- error_description: z.string(),
- }),
+ schema: resolver(
+ z.object({
+ error: z.string(),
+ error_description: z.string(),
+ }),
+ ),
},
},
},
},
}),
+ jsonOrForm(),
+ plugin.middleware,
+ validator(
+ "json",
+ z.object({
+ code: z.string().optional(),
+ code_verifier: z.string().optional(),
+ grant_type: z
+ .enum([
+ "authorization_code",
+ "refresh_token",
+ "client_credentials",
+ "password",
+ "urn:ietf:params:oauth:grant-type:device_code",
+ "urn:ietf:params:oauth:grant-type:token-exchange",
+ "urn:ietf:params:oauth:grant-type:saml2-bearer",
+ "urn:openid:params:grant-type:ciba",
+ ])
+ .default("authorization_code"),
+ client_id: z.string().optional(),
+ client_secret: z.string().optional(),
+ username: z.string().trim().optional(),
+ password: z.string().trim().optional(),
+ redirect_uri: z.string().url().optional(),
+ refresh_token: z.string().optional(),
+ scope: z.string().optional(),
+ assertion: z.string().optional(),
+ audience: z.string().optional(),
+ subject_token_type: z.string().optional(),
+ subject_token: z.string().optional(),
+ actor_token_type: z.string().optional(),
+ actor_token: z.string().optional(),
+ auth_req_id: z.string().optional(),
+ }),
+ handleZodError,
+ ),
async (context) => {
const {
grant_type,
diff --git a/plugins/openid/routes/sso/:id/index.ts b/plugins/openid/routes/sso/:id/index.ts
index 278bbbac..df2ba8d9 100644
--- a/plugins/openid/routes/sso/:id/index.ts
+++ b/plugins/openid/routes/sso/:id/index.ts
@@ -1,49 +1,46 @@
-import { auth } from "@/api";
+import { auth, handleZodError } from "@/api";
import { proxyUrl } from "@/response";
-import { createRoute, z } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas";
import { db } from "@versia/kit/db";
import { type SQL, eq } from "@versia/kit/drizzle";
import { OpenIdAccounts } from "@versia/kit/tables";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import type { PluginType } from "~/plugins/openid";
export default (plugin: PluginType): void => {
- plugin.registerRoute("/api/v1/sso", (app) => {
- app.openapi(
- createRoute({
- method: "get",
- path: "/api/v1/sso/{id}",
+ plugin.registerRoute("/api/v1/sso/{id}", (app) => {
+ app.get(
+ "/api/v1/sso/:id",
+ describeRoute({
summary: "Get linked account",
tags: ["SSO"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.OAuth],
- }),
- plugin.middleware,
- ] as const,
- request: {
- params: z.object({
- id: z.string(),
- }),
- },
responses: {
200: {
description: "Linked account",
content: {
"application/json": {
- schema: z.object({
- id: z.string(),
- name: z.string(),
- icon: z.string().optional(),
- }),
+ schema: resolver(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ icon: z.string().optional(),
+ }),
+ ),
},
},
},
404: ApiError.accountNotFound().schema,
},
}),
+ auth({
+ auth: true,
+ permissions: [RolePermission.OAuth],
+ }),
+ plugin.middleware,
+ validator("param", z.object({ id: z.string() }), handleZodError),
async (context) => {
const { id: issuerId } = context.req.valid("param");
const { user } = context.get("auth");
@@ -89,24 +86,11 @@ export default (plugin: PluginType): void => {
},
);
- app.openapi(
- createRoute({
- method: "delete",
- path: "/api/v1/sso/{id}",
+ app.delete(
+ "/api/v1/sso/:id",
+ describeRoute({
summary: "Unlink account",
tags: ["SSO"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.OAuth],
- }),
- plugin.middleware,
- ] as const,
- request: {
- params: z.object({
- id: z.string(),
- }),
- },
responses: {
204: {
description: "Account unlinked",
@@ -115,12 +99,18 @@ export default (plugin: PluginType): void => {
description: "Account not found",
content: {
"application/json": {
- schema: ApiError.zodSchema,
+ schema: resolver(ApiError.zodSchema),
},
},
},
},
}),
+ auth({
+ auth: true,
+ permissions: [RolePermission.OAuth],
+ }),
+ plugin.middleware,
+ validator("param", z.object({ id: z.string() }), handleZodError),
async (context) => {
const { id: issuerId } = context.req.valid("param");
const { user } = context.get("auth");
diff --git a/plugins/openid/routes/sso/index.ts b/plugins/openid/routes/sso/index.ts
index 87c554ce..7c172a7b 100644
--- a/plugins/openid/routes/sso/index.ts
+++ b/plugins/openid/routes/sso/index.ts
@@ -1,48 +1,49 @@
-import { auth } from "@/api";
-import { z } from "@hono/zod-openapi";
+import { auth, handleZodError } from "@/api";
import { RolePermission } from "@versia/client/schemas";
import { Application, db } from "@versia/kit/db";
import { OpenIdLoginFlows } from "@versia/kit/tables";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator } from "hono-openapi/zod";
import {
calculatePKCECodeChallenge,
generateRandomCodeVerifier,
} from "oauth4webapi";
+import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error.ts";
import type { PluginType } from "../../index.ts";
import { oauthDiscoveryRequest, oauthRedirectUri } from "../../utils.ts";
export default (plugin: PluginType): void => {
plugin.registerRoute("/api/v1/sso", (app) => {
- app.openapi(
- {
- method: "get",
- path: "/api/v1/sso",
+ app.get(
+ "/api/v1/sso",
+ describeRoute({
summary: "Get linked accounts",
tags: ["SSO"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.OAuth],
- }),
- plugin.middleware,
- ] as const,
responses: {
200: {
description: "Linked accounts",
content: {
"application/json": {
- schema: z.array(
- z.object({
- id: z.string(),
- name: z.string(),
- icon: z.string().optional(),
- }),
+ schema: resolver(
+ z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ icon: z.string().optional(),
+ }),
+ ),
),
},
},
},
},
- },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.OAuth],
+ }),
+ plugin.middleware,
async (context) => {
const { user } = context.get("auth");
@@ -61,30 +62,11 @@ export default (plugin: PluginType): void => {
},
);
- app.openapi(
- {
- method: "post",
- path: "/api/v1/sso",
+ app.post(
+ "/api/v1/sso",
+ describeRoute({
summary: "Link account",
tags: ["SSO"],
- middleware: [
- auth({
- auth: true,
- permissions: [RolePermission.OAuth],
- }),
- plugin.middleware,
- ] as const,
- request: {
- body: {
- content: {
- "application/json": {
- schema: z.object({
- issuer: z.string(),
- }),
- },
- },
- },
- },
responses: {
302: {
description: "Redirect to OpenID provider",
@@ -93,12 +75,18 @@ export default (plugin: PluginType): void => {
description: "Issuer not found",
content: {
"application/json": {
- schema: ApiError.zodSchema,
+ schema: resolver(ApiError.zodSchema),
},
},
},
},
- },
+ }),
+ auth({
+ auth: true,
+ permissions: [RolePermission.OAuth],
+ }),
+ plugin.middleware,
+ validator("json", z.object({ issuer: z.string() }), handleZodError),
async (context) => {
const { user } = context.get("auth");
diff --git a/routes.ts b/routes.ts
index 07700b7c..02d48566 100644
--- a/routes.ts
+++ b/routes.ts
@@ -9,6 +9,8 @@ export const routeMatcher = new Bun.FileSystemRouter({
export const routes = Object.fromEntries(
Object.entries(routeMatcher.routes)
.filter(([route]) => !route.endsWith(".test"))
- .map(([route, path]) => [route, path.replace(join(process.cwd()), ".")])
- .reverse(),
+ .map(([route, path]) => [
+ route,
+ path.replace(join(process.cwd()), "."),
+ ]),
) as Record;
diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts
index 047f25c4..e466a1e8 100644
--- a/tests/oauth.test.ts
+++ b/tests/oauth.test.ts
@@ -2,8 +2,8 @@
* @deprecated
*/
import { afterAll, describe, expect, test } from "bun:test";
-import type { z } from "@hono/zod-openapi";
import type { Application, Token } from "@versia/client/schemas";
+import type { z } from "zod";
import { fakeRequest, getTestUsers } from "./utils.ts";
let clientId: string;
diff --git a/types/api.ts b/types/api.ts
index c528412d..e6cb1a46 100644
--- a/types/api.ts
+++ b/types/api.ts
@@ -1,5 +1,3 @@
-import type { OpenAPIHono } from "@hono/zod-openapi";
-import type { z } from "@hono/zod-openapi";
import type {
Delete,
Follow,
@@ -12,7 +10,9 @@ import type {
User,
} from "@versia/federation/types";
import type { SocketAddress } from "bun";
+import type { Hono } from "hono";
import type { RouterRoute } from "hono/types";
+import type { z } from "zod";
import type { ConfigSchema } from "~/classes/config/schema";
import type { AuthData } from "~/classes/functions/user";
@@ -29,7 +29,7 @@ export type HonoEnv = {
};
export interface ApiRouteExports {
- default: (app: OpenAPIHono) => RouterRoute;
+ default: (app: Hono) => RouterRoute;
}
export type KnownEntity =
diff --git a/utils/api.ts b/utils/api.ts
index 26efdb23..fb91338a 100644
--- a/utils/api.ts
+++ b/utils/api.ts
@@ -1,6 +1,3 @@
-import type { OpenAPIHono } from "@hono/zod-openapi";
-import { z } from "@hono/zod-openapi";
-import { zValidator } from "@hono/zod-validator";
import { getLogger } from "@logtape/logtape";
import type { RolePermission } from "@versia/client/schemas";
import { Application, Emoji, Note, Token, User, db } from "@versia/kit/db";
@@ -8,7 +5,8 @@ import { Challenges } from "@versia/kit/tables";
import { extractParams, verifySolution } from "altcha-lib";
import chalk from "chalk";
import { type SQL, eq } from "drizzle-orm";
-import type { Context, MiddlewareHandler } from "hono";
+import type { Context, Hono, MiddlewareHandler } from "hono";
+import { validator } from "hono-openapi/zod";
import { every } from "hono/combine";
import { createMiddleware } from "hono/factory";
import {
@@ -26,14 +24,14 @@ import {
oneOrMore,
} from "magic-regexp";
import { type ParsedQs, parse } from "qs";
+import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import { ApiError } from "~/classes/errors/api-error";
import type { AuthData } from "~/classes/functions/user";
import { config } from "~/config.ts";
import type { HonoEnv } from "~/types/api";
-export const apiRoute = (fn: (app: OpenAPIHono) => void): typeof fn =>
- fn;
+export const apiRoute = (fn: (app: Hono) => void): typeof fn => fn;
export const idValidator = createRegExp(
anyOf(digit, charIn("ABCDEF")).times(8),
@@ -310,7 +308,7 @@ type WithIdParam = {
* @returns MiddlewareHandler
*/
export const withNoteParam = every(
- zValidator("param", z.object({ id: z.string().uuid() }), handleZodError),
+ validator("param", z.object({ id: z.string().uuid() }), handleZodError),
createMiddleware<
HonoEnv & {
Variables: {
@@ -348,7 +346,7 @@ export const withNoteParam = every(
* @returns MiddlewareHandler
*/
export const withUserParam = every(
- zValidator("param", z.object({ id: z.string().uuid() }), handleZodError),
+ validator("param", z.object({ id: z.string().uuid() }), handleZodError),
createMiddleware<
HonoEnv & {
Variables: {
@@ -384,7 +382,7 @@ export const withUserParam = every(
* @returns
*/
export const withEmojiParam = every(
- zValidator("param", z.object({ id: z.string().uuid() }), handleZodError),
+ validator("param", z.object({ id: z.string().uuid() }), handleZodError),
createMiddleware<
HonoEnv & {
Variables: {
diff --git a/utils/bull-board.ts b/utils/bull-board.ts
index f860d2c3..e4ae1675 100644
--- a/utils/bull-board.ts
+++ b/utils/bull-board.ts
@@ -1,7 +1,7 @@
import { createBullBoard } from "@bull-board/api";
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
import { HonoAdapter } from "@bull-board/hono";
-import type { OpenAPIHono } from "@hono/zod-openapi";
+import type { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { deliveryQueue } from "~/classes/queues/delivery";
import { fetchQueue } from "~/classes/queues/fetch";
@@ -12,7 +12,7 @@ import { config } from "~/config.ts";
import pkg from "~/package.json";
import type { HonoEnv } from "~/types/api";
-export const applyToHono = (app: OpenAPIHono): void => {
+export const applyToHono = (app: Hono): void => {
const serverAdapter = new HonoAdapter(serveStatic);
createBullBoard({
diff --git a/utils/server.ts b/utils/server.ts
index 6288145a..21c09748 100644
--- a/utils/server.ts
+++ b/utils/server.ts
@@ -1,12 +1,13 @@
-import type { OpenAPIHono, z } from "@hono/zod-openapi";
import type { Server } from "bun";
+import type { Hono } from "hono";
+import type { z } from "zod";
import type { ConfigSchema } from "~/classes/config/schema.ts";
import type { HonoEnv } from "~/types/api";
import { debugResponse } from "./api.ts";
export const createServer = (
config: z.infer,
- app: OpenAPIHono,
+ app: Hono,
): Server =>
Bun.serve({
port: config.http.bind_port,