diff --git a/api/api/auth/login/index.ts b/api/api/auth/login/index.ts index 790689ff..549f1e2a 100644 --- a/api/api/auth/login/index.ts +++ b/api/api/auth/login/index.ts @@ -7,6 +7,7 @@ import type { Context } from "hono"; import { setCookie } from "hono/cookie"; import { SignJWT } from "jose"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; export const meta = applyConfig({ @@ -203,7 +204,7 @@ export default apiRoute((app) => const application = await Application.fromClientId(client_id); if (!application) { - return context.json({ error: "Invalid application" }, 400); + throw new ApiError(400, "Invalid application"); } const searchParams = new URLSearchParams({ diff --git a/api/api/v1/accounts/:id/block.ts b/api/api/v1/accounts/:id/block.ts index 3b1af76f..66ba4711 100644 --- a/api/api/v1/accounts/:id/block.ts +++ b/api/api/v1/accounts/:id/block.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -72,13 +73,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const foundRelationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/accounts/:id/follow.ts b/api/api/v1/accounts/:id/follow.ts index 2f5953c7..6164668c 100644 --- a/api/api/v1/accounts/:id/follow.ts +++ b/api/api/v1/accounts/:id/follow.ts @@ -4,6 +4,7 @@ import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import ISO6391 from "iso-639-1"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -91,13 +92,13 @@ export default apiRoute((app) => const { reblogs, notify, languages } = context.req.valid("json"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } let relationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/accounts/:id/followers.ts b/api/api/v1/accounts/:id/followers.ts index 9cd05260..0932c99a 100644 --- a/api/api/v1/accounts/:id/followers.ts +++ b/api/api/v1/accounts/:id/followers.ts @@ -4,6 +4,7 @@ import { Timeline, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -82,7 +83,7 @@ export default apiRoute((app) => // TODO: Add follower/following privacy settings if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const { objects, link } = await Timeline.getUserTimeline( diff --git a/api/api/v1/accounts/:id/following.ts b/api/api/v1/accounts/:id/following.ts index 02a83174..7d8b5da2 100644 --- a/api/api/v1/accounts/:id/following.ts +++ b/api/api/v1/accounts/:id/following.ts @@ -4,6 +4,7 @@ import { Timeline, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -81,7 +82,7 @@ export default apiRoute((app) => const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } // TODO: Add follower/following privacy settings diff --git a/api/api/v1/accounts/:id/index.ts b/api/api/v1/accounts/:id/index.ts index 005332ed..360c6c26 100644 --- a/api/api/v1/accounts/:id/index.ts +++ b/api/api/v1/accounts/:id/index.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -63,7 +64,7 @@ export default apiRoute((app) => const foundUser = await User.fromId(id); if (!foundUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } return context.json(foundUser.toApi(user?.id === foundUser.id), 200); diff --git a/api/api/v1/accounts/:id/mute.ts b/api/api/v1/accounts/:id/mute.ts index fe224ed6..4cf68d2c 100644 --- a/api/api/v1/accounts/:id/mute.ts +++ b/api/api/v1/accounts/:id/mute.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -90,13 +91,13 @@ export default apiRoute((app) => const { notifications } = context.req.valid("json"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const foundRelationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/accounts/:id/note.ts b/api/api/v1/accounts/:id/note.ts index 17570213..b2a7e225 100644 --- a/api/api/v1/accounts/:id/note.ts +++ b/api/api/v1/accounts/:id/note.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -83,13 +84,13 @@ export default apiRoute((app) => const { comment } = context.req.valid("json"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const foundRelationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/accounts/:id/pin.ts b/api/api/v1/accounts/:id/pin.ts index 3dba343f..12362e3f 100644 --- a/api/api/v1/accounts/:id/pin.ts +++ b/api/api/v1/accounts/:id/pin.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -72,13 +73,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const foundRelationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/accounts/:id/refetch.ts b/api/api/v1/accounts/:id/refetch.ts index 522952c3..28220bcf 100644 --- a/api/api/v1/accounts/:id/refetch.ts +++ b/api/api/v1/accounts/:id/refetch.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -77,17 +78,17 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } if (otherUser.isLocal()) { - return context.json({ error: "Cannot refetch a local user" }, 400); + throw new ApiError(400, "Cannot refetch a local user"); } const newUser = await otherUser.updateFromRemote(); diff --git a/api/api/v1/accounts/:id/remove_from_followers.ts b/api/api/v1/accounts/:id/remove_from_followers.ts index 2b5deb3f..f6a774ba 100644 --- a/api/api/v1/accounts/:id/remove_from_followers.ts +++ b/api/api/v1/accounts/:id/remove_from_followers.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -72,13 +73,13 @@ export default apiRoute((app) => const { user: self } = context.get("auth"); if (!self) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const oppositeRelationship = await Relationship.fromOwnerAndSubject( 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 index 5147da3a..2bf596d2 100644 --- a/api/api/v1/accounts/:id/roles/:role_id/index.test.ts +++ b/api/api/v1/accounts/:id/roles/:role_id/index.test.ts @@ -116,7 +116,9 @@ describe("/api/v1/accounts/:id/roles/:role_id", () => { expect(response.status).toBe(403); const output = await response.json(); expect(output).toMatchObject({ - error: `Cannot assign role 'higherPriorityRole' with priority 3 to user: your highest role priority is 2`, + error: "Forbidden", + details: + "User with highest role priority 2 cannot assign role with priority 3", }); }); @@ -156,7 +158,9 @@ describe("/api/v1/accounts/:id/roles/:role_id", () => { expect(response.status).toBe(403); const output = await response.json(); expect(output).toMatchObject({ - error: `Cannot remove role 'higherPriorityRole' with priority 3 from user: your highest role priority is 2`, + error: "Forbidden", + details: + "User with highest role priority 2 cannot remove role with priority 3", }); await higherPriorityRole.unlinkUser(users[1].id); diff --git a/api/api/v1/accounts/:id/roles/:role_id/index.ts b/api/api/v1/accounts/:id/roles/:role_id/index.ts index e9a9e2df..824c456a 100644 --- a/api/api/v1/accounts/:id/roles/:role_id/index.ts +++ b/api/api/v1/accounts/:id/roles/:role_id/index.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Role, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -114,18 +115,18 @@ export default apiRoute((app) => { const { id, role_id } = context.req.valid("param"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const targetUser = await User.fromId(id); const role = await Role.fromId(role_id); if (!role) { - return context.json({ error: "Role not found" }, 404); + throw new ApiError(404, "Role not found"); } if (!targetUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } // Priority check @@ -136,11 +137,10 @@ export default apiRoute((app) => { ); if (role.data.priority > userHighestRole.data.priority) { - return context.json( - { - error: `Cannot assign role '${role.data.name}' with priority ${role.data.priority} to user: your highest role priority is ${userHighestRole.data.priority}`, - }, + throw new ApiError( 403, + "Forbidden", + `User with highest role priority ${userHighestRole.data.priority} cannot assign role with priority ${role.data.priority}`, ); } @@ -154,18 +154,18 @@ export default apiRoute((app) => { const { id, role_id } = context.req.valid("param"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const targetUser = await User.fromId(id); const role = await Role.fromId(role_id); if (!role) { - return context.json({ error: "Role not found" }, 404); + throw new ApiError(404, "Role not found"); } if (!targetUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } // Priority check @@ -176,11 +176,10 @@ export default apiRoute((app) => { ); if (role.data.priority > userHighestRole.data.priority) { - return context.json( - { - error: `Cannot remove role '${role.data.name}' with priority ${role.data.priority} from user: your highest role priority is ${userHighestRole.data.priority}`, - }, + throw new ApiError( 403, + "Forbidden", + `User with highest role priority ${userHighestRole.data.priority} cannot remove role with priority ${role.data.priority}`, ); } diff --git a/api/api/v1/accounts/:id/roles/index.ts b/api/api/v1/accounts/:id/roles/index.ts index cfade6af..edbfcd5f 100644 --- a/api/api/v1/accounts/:id/roles/index.ts +++ b/api/api/v1/accounts/:id/roles/index.ts @@ -2,6 +2,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { Role, User } from "@versia/kit/db"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -59,7 +60,7 @@ export default apiRoute((app) => { const targetUser = await User.fromId(id); if (!targetUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const roles = await Role.getUserRoles( diff --git a/api/api/v1/accounts/:id/statuses.ts b/api/api/v1/accounts/:id/statuses.ts index 8a1dc7b2..1e489b7e 100644 --- a/api/api/v1/accounts/:id/statuses.ts +++ b/api/api/v1/accounts/:id/statuses.ts @@ -4,6 +4,7 @@ import { Note, Timeline, User } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -96,7 +97,7 @@ export default apiRoute((app) => const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const { diff --git a/api/api/v1/accounts/:id/unblock.ts b/api/api/v1/accounts/:id/unblock.ts index a6f632ed..252fdc7b 100644 --- a/api/api/v1/accounts/:id/unblock.ts +++ b/api/api/v1/accounts/:id/unblock.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -72,13 +73,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const foundRelationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/accounts/:id/unfollow.ts b/api/api/v1/accounts/:id/unfollow.ts index af998713..999808ba 100644 --- a/api/api/v1/accounts/:id/unfollow.ts +++ b/api/api/v1/accounts/:id/unfollow.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -80,13 +81,13 @@ export default apiRoute((app) => const { user: self } = context.get("auth"); if (!self) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const foundRelationship = await Relationship.fromOwnerAndSubject( @@ -94,9 +95,7 @@ export default apiRoute((app) => otherUser, ); - if (!(await self.unfollow(otherUser, foundRelationship))) { - return context.json({ error: "Failed to unfollow user" }, 500); - } + await self.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 index 388bf8b2..c8f82f94 100644 --- a/api/api/v1/accounts/:id/unmute.ts +++ b/api/api/v1/accounts/:id/unmute.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -72,13 +73,13 @@ export default apiRoute((app) => const { user: self } = context.get("auth"); if (!self) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const user = await User.fromId(id); if (!user) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const foundRelationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/accounts/:id/unpin.ts b/api/api/v1/accounts/:id/unpin.ts index d5215798..2e3b38cc 100644 --- a/api/api/v1/accounts/:id/unpin.ts +++ b/api/api/v1/accounts/:id/unpin.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -72,13 +73,13 @@ export default apiRoute((app) => const { user: self } = context.get("auth"); if (!self) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const otherUser = await User.fromId(id); if (!otherUser) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } const foundRelationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/accounts/familiar_followers/index.ts b/api/api/v1/accounts/familiar_followers/index.ts index 3935b910..c77cb782 100644 --- a/api/api/v1/accounts/familiar_followers/index.ts +++ b/api/api/v1/accounts/familiar_followers/index.ts @@ -4,6 +4,7 @@ import { User, db } from "@versia/kit/db"; import { RolePermissions, type Users } from "@versia/kit/tables"; import { type InferSelectModel, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -73,7 +74,7 @@ export default apiRoute((app) => const { id: ids } = context.req.valid("query"); if (!self) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } // Find followers of the accounts in "ids", that you also follow @@ -84,7 +85,7 @@ export default apiRoute((app) => ( await db.execute(sql>` SELECT "Users"."id" FROM "Users" - INNER JOIN "Relationships" AS "SelfFollowing" + INNER JOIN "Relationships" AS "SelfFollowing" ON "SelfFollowing"."subjectId" = "Users"."id" WHERE "SelfFollowing"."ownerId" = ${self.id} AND "SelfFollowing"."following" = true diff --git a/api/api/v1/accounts/id/index.ts b/api/api/v1/accounts/id/index.ts index b35a0fd2..ca58bbb0 100644 --- a/api/api/v1/accounts/id/index.ts +++ b/api/api/v1/accounts/id/index.ts @@ -4,6 +4,7 @@ import { User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -65,7 +66,7 @@ export default apiRoute((app) => ); if (!user) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } return context.json(user.toApi(), 200); diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts index baaecca5..108c0442 100644 --- a/api/api/v1/accounts/index.ts +++ b/api/api/v1/accounts/index.ts @@ -6,6 +6,7 @@ import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import ISO6391 from "iso-639-1"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; export const meta = applyConfig({ @@ -143,12 +144,7 @@ export default apiRoute((app) => context.req.valid("json"); if (!config.signups.registration) { - return context.json( - { - error: "Registration is disabled", - }, - 422, - ); + throw new ApiError(422, "Registration is disabled"); } const errors: { @@ -318,16 +314,14 @@ export default apiRoute((app) => .join(", ")}`, ) .join(", "); - return context.json( - { - error: `Validation failed: ${errorsText}`, - details: Object.fromEntries( - Object.entries(errors.details).filter( - ([_, errors]) => errors.length > 0, - ), - ), - }, + throw new ApiError( 422, + `Validation failed: ${errorsText}`, + Object.fromEntries( + Object.entries(errors.details).filter( + ([_, errors]) => errors.length > 0, + ), + ), ); } diff --git a/api/api/v1/accounts/lookup/index.ts b/api/api/v1/accounts/lookup/index.ts index 055d80f2..6a5abd73 100644 --- a/api/api/v1/accounts/lookup/index.ts +++ b/api/api/v1/accounts/lookup/index.ts @@ -4,6 +4,7 @@ import { Instance, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -118,7 +119,7 @@ export default apiRoute((app) => const uri = await User.webFinger(manager, username, domain); if (!uri) { - return context.json({ error: "Account not found" }, 404); + throw new ApiError(404, "Account not found"); } const foundAccount = await User.resolve(uri); @@ -127,6 +128,6 @@ export default apiRoute((app) => return context.json(foundAccount.toApi(), 200); } - return context.json({ error: "Account not found" }, 404); + throw new ApiError(404, "Account not found"); }), ); diff --git a/api/api/v1/accounts/relationships/index.ts b/api/api/v1/accounts/relationships/index.ts index fb150c2c..5798f602 100644 --- a/api/api/v1/accounts/relationships/index.ts +++ b/api/api/v1/accounts/relationships/index.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -63,7 +64,7 @@ export default apiRoute((app) => const ids = Array.isArray(id) ? id : [id]; if (!self) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const relationships = await Relationship.fromOwnerAndSubjects( diff --git a/api/api/v1/accounts/search/index.ts b/api/api/v1/accounts/search/index.ts index 3cf9f41e..78b213ab 100644 --- a/api/api/v1/accounts/search/index.ts +++ b/api/api/v1/accounts/search/index.ts @@ -11,6 +11,7 @@ import { RolePermissions, Users } from "@versia/kit/tables"; import { eq, ilike, not, or, sql } from "drizzle-orm"; import stringComparison from "string-comparison"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -80,7 +81,7 @@ export default apiRoute((app) => const { user: self } = context.get("auth"); if (!self && following) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { username, domain } = parseUserAddress(q); diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index 4316374a..06b10cce 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -8,6 +8,7 @@ import { RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import ISO6391 from "iso-639-1"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { contentToHtml } from "~/classes/functions/status"; import { MediaManager } from "~/classes/media/media-manager"; import { config } from "~/packages/config-manager/index.ts"; @@ -213,7 +214,7 @@ export default apiRoute((app) => } = context.req.valid("json"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const self = user.data; @@ -257,10 +258,7 @@ export default apiRoute((app) => ); if (existingUser) { - return context.json( - { error: "Username is already taken" }, - 422, - ); + throw new ApiError(422, "Username is already taken"); } self.username = username; @@ -402,7 +400,7 @@ export default apiRoute((app) => const output = await User.fromId(self.id); if (!output) { - return context.json({ error: "Couldn't edit user" }, 500); + 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 07c0b4ee..e21e7e15 100644 --- a/api/api/v1/accounts/verify_credentials/index.ts +++ b/api/api/v1/accounts/verify_credentials/index.ts @@ -1,6 +1,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -47,7 +48,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } return context.json(user.toApi(true), 200); diff --git a/api/api/v1/apps/verify_credentials/index.ts b/api/api/v1/apps/verify_credentials/index.ts index bfed5f6d..a7804962 100644 --- a/api/api/v1/apps/verify_credentials/index.ts +++ b/api/api/v1/apps/verify_credentials/index.ts @@ -2,6 +2,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { Application } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -49,10 +50,10 @@ export default apiRoute((app) => const { user, token } = context.get("auth"); if (!token) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const application = await Application.getFromToken( @@ -60,7 +61,7 @@ export default apiRoute((app) => ); if (!application) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } return context.json( diff --git a/api/api/v1/blocks/index.ts b/api/api/v1/blocks/index.ts index 83e36476..f860b1cc 100644 --- a/api/api/v1/blocks/index.ts +++ b/api/api/v1/blocks/index.ts @@ -4,6 +4,7 @@ import { Timeline, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -66,7 +67,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { objects: blocks, link } = await Timeline.getUserTimeline( diff --git a/api/api/v1/challenges/index.ts b/api/api/v1/challenges/index.ts index 5f6ee26c..5e4145de 100644 --- a/api/api/v1/challenges/index.ts +++ b/api/api/v1/challenges/index.ts @@ -1,6 +1,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { generateChallenge } from "@/challenges"; import { createRoute, z } from "@hono/zod-openapi"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -54,10 +55,7 @@ const route = createRoute({ export default apiRoute((app) => app.openapi(route, async (context) => { if (!config.validation.challenges.enabled) { - return context.json( - { error: "Challenges are disabled in config" }, - 400, - ); + throw new ApiError(400, "Challenges are disabled in config"); } const result = await generateChallenge(); diff --git a/api/api/v1/emojis/:id/index.ts b/api/api/v1/emojis/:id/index.ts index c91a91cd..0c2a8ceb 100644 --- a/api/api/v1/emojis/:id/index.ts +++ b/api/api/v1/emojis/:id/index.ts @@ -5,6 +5,7 @@ import { Attachment, Emoji, db } from "@versia/kit/db"; import { Emojis, RolePermissions } from "@versia/kit/tables"; import { eq } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { MediaManager } from "~/classes/media/media-manager"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -212,13 +213,13 @@ export default apiRoute((app) => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const emoji = await Emoji.fromId(id); if (!emoji) { - return context.json({ error: "Emoji not found" }, 404); + throw new ApiError(404, "Emoji not found"); } // Check if user is admin @@ -226,11 +227,10 @@ export default apiRoute((app) => { !user.hasPermission(RolePermissions.ManageEmojis) && emoji.data.ownerId !== user.data.id ) { - return context.json( - { - error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`, - }, + throw new ApiError( 403, + "Cannot modify emoji not owned by you", + `This emoji is either global (and you do not have the '${RolePermissions.ManageEmojis}' permission) or not owned by you`, ); } @@ -242,13 +242,13 @@ export default apiRoute((app) => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const emoji = await Emoji.fromId(id); if (!emoji) { - return context.json({ error: "Emoji not found" }, 404); + throw new ApiError(404, "Emoji not found"); } // Check if user is admin @@ -256,11 +256,10 @@ export default apiRoute((app) => { !user.hasPermission(RolePermissions.ManageEmojis) && emoji.data.ownerId !== user.data.id ) { - return context.json( - { - error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`, - }, + throw new ApiError( 403, + "Cannot modify emoji not owned by you", + `This emoji is either global (and you do not have the '${RolePermissions.ManageEmojis}' permission) or not owned by you`, ); } @@ -275,11 +274,10 @@ export default apiRoute((app) => { } = context.req.valid("json"); if (!user.hasPermission(RolePermissions.ManageEmojis) && emojiGlobal) { - return context.json( - { - error: `Only users with the '${RolePermissions.ManageEmojis}' permission can make an emoji global or not`, - }, + throw new ApiError( 401, + "Missing permissions", + `'${RolePermissions.ManageEmojis}' permission is needed to upload global emojis`, ); } @@ -293,11 +291,10 @@ export default apiRoute((app) => { : await mimeLookup(element); if (!contentType.startsWith("image/")) { - return context.json( - { - error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, - }, + throw new ApiError( 422, + "Invalid content type", + `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, ); } @@ -334,13 +331,13 @@ export default apiRoute((app) => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const emoji = await Emoji.fromId(id); if (!emoji) { - return context.json({ error: "Emoji not found" }, 404); + throw new ApiError(404, "Emoji not found"); } // Check if user is admin @@ -348,11 +345,10 @@ export default apiRoute((app) => { !user.hasPermission(RolePermissions.ManageEmojis) && emoji.data.ownerId !== user.data.id ) { - return context.json( - { - error: `You cannot delete this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`, - }, + throw new ApiError( 403, + "Cannot delete emoji not owned by you", + `This emoji is either global (and you do not have the '${RolePermissions.ManageEmojis}' permission) or not owned by you`, ); } diff --git a/api/api/v1/emojis/index.ts b/api/api/v1/emojis/index.ts index 9999556e..d2b7e73a 100644 --- a/api/api/v1/emojis/index.ts +++ b/api/api/v1/emojis/index.ts @@ -5,6 +5,7 @@ import { Attachment, Emoji } from "@versia/kit/db"; import { Emojis, RolePermissions } from "@versia/kit/tables"; import { and, eq, isNull, or } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { MediaManager } from "~/classes/media/media-manager"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -117,15 +118,14 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } if (!user.hasPermission(RolePermissions.ManageEmojis) && global) { - return context.json( - { - error: `Only users with the '${RolePermissions.ManageEmojis}' permission can upload global emojis`, - }, + throw new ApiError( 401, + "Missing permissions", + `Only users with the '${RolePermissions.ManageEmojis}' permission can upload global emojis`, ); } @@ -139,11 +139,10 @@ export default apiRoute((app) => ); if (existing) { - return context.json( - { - error: `An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`, - }, + throw new ApiError( 422, + "Emoji already exists", + `An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`, ); } @@ -154,11 +153,10 @@ export default apiRoute((app) => element instanceof File ? element.type : await mimeLookup(element); if (!contentType.startsWith("image/")) { - return context.json( - { - error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, - }, + throw new ApiError( 422, + "Invalid content type", + `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, ); } diff --git a/api/api/v1/favourites/index.ts b/api/api/v1/favourites/index.ts index 746d13d5..fae94f51 100644 --- a/api/api/v1/favourites/index.ts +++ b/api/api/v1/favourites/index.ts @@ -4,6 +4,7 @@ import { Note, Timeline } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -64,7 +65,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { objects: favourites, link } = await Timeline.getNoteTimeline( diff --git a/api/api/v1/follow_requests/:account_id/authorize.ts b/api/api/v1/follow_requests/:account_id/authorize.ts index fc3d698a..54b11342 100644 --- a/api/api/v1/follow_requests/:account_id/authorize.ts +++ b/api/api/v1/follow_requests/:account_id/authorize.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -66,7 +67,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { account_id } = context.req.valid("param"); @@ -74,7 +75,7 @@ export default apiRoute((app) => const account = await User.fromId(account_id); if (!account) { - return context.json({ error: "Account not found" }, 404); + throw new ApiError(404, "Account not found"); } const oppositeRelationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/follow_requests/:account_id/reject.ts b/api/api/v1/follow_requests/:account_id/reject.ts index 37539ad8..326c526c 100644 --- a/api/api/v1/follow_requests/:account_id/reject.ts +++ b/api/api/v1/follow_requests/:account_id/reject.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Relationship, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -66,7 +67,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { account_id } = context.req.valid("param"); @@ -74,7 +75,7 @@ export default apiRoute((app) => const account = await User.fromId(account_id); if (!account) { - return context.json({ error: "Account not found" }, 404); + throw new ApiError(404, "Account not found"); } const oppositeRelationship = await Relationship.fromOwnerAndSubject( diff --git a/api/api/v1/follow_requests/index.ts b/api/api/v1/follow_requests/index.ts index 0dbf78ba..5887a232 100644 --- a/api/api/v1/follow_requests/index.ts +++ b/api/api/v1/follow_requests/index.ts @@ -4,6 +4,7 @@ import { Timeline, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -64,7 +65,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { objects: followRequests, link } = diff --git a/api/api/v1/markers/index.ts b/api/api/v1/markers/index.ts index a9265559..0549f2d6 100644 --- a/api/api/v1/markers/index.ts +++ b/api/api/v1/markers/index.ts @@ -5,6 +5,7 @@ import { db } from "@versia/kit/db"; import { Markers, RolePermissions } from "@versia/kit/tables"; import { type SQL, and, eq } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -119,7 +120,7 @@ export default apiRoute((app) => { const timeline = Array.isArray(timelines) ? timelines : []; if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } if (!timeline) { @@ -191,7 +192,7 @@ export default apiRoute((app) => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const markers: ApiMarker = { diff --git a/api/api/v1/media/:id/index.ts b/api/api/v1/media/:id/index.ts index 1f6f6b99..4d916319 100644 --- a/api/api/v1/media/:id/index.ts +++ b/api/api/v1/media/:id/index.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Attachment } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { MediaManager } from "~/classes/media/media-manager"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -122,7 +123,7 @@ export default apiRoute((app) => { const attachment = await Attachment.fromId(id); if (!attachment) { - return context.json({ error: "Media not found" }, 404); + throw new ApiError(404, "Media not found"); } const { description, thumbnail } = context.req.valid("form"); @@ -159,7 +160,7 @@ export default apiRoute((app) => { const attachment = await Attachment.fromId(id); if (!attachment) { - return context.json({ error: "Media not found" }, 404); + throw new ApiError(404, "Media not found"); } return context.json(attachment.toApi(), 200); diff --git a/api/api/v1/media/index.ts b/api/api/v1/media/index.ts index d053eaea..05634f28 100644 --- a/api/api/v1/media/index.ts +++ b/api/api/v1/media/index.ts @@ -4,6 +4,7 @@ import { Attachment } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import sharp from "sharp"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { MediaManager } from "~/classes/media/media-manager"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -90,11 +91,9 @@ export default apiRoute((app) => const { file, thumbnail, description } = context.req.valid("form"); if (file.size > config.validation.max_media_size) { - return context.json( - { - error: `File too large, max size is ${config.validation.max_media_size} bytes`, - }, + throw new ApiError( 413, + `File too large, max size is ${config.validation.max_media_size} bytes`, ); } @@ -102,7 +101,11 @@ export default apiRoute((app) => config.validation.enforce_mime_types && !config.validation.allowed_mime_types.includes(file.type) ) { - return context.json({ error: "Disallowed file type" }, 415); + throw new ApiError( + 415, + `File type ${file.type} is not allowed`, + `Allowed types: ${config.validation.allowed_mime_types.join(", ")}`, + ); } const sha256 = new Bun.SHA256(); diff --git a/api/api/v1/mutes/index.ts b/api/api/v1/mutes/index.ts index a1ce13f3..2cbe34e3 100644 --- a/api/api/v1/mutes/index.ts +++ b/api/api/v1/mutes/index.ts @@ -4,6 +4,7 @@ import { Timeline, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -64,7 +65,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { objects: mutes, link } = await Timeline.getUserTimeline( diff --git a/api/api/v1/notifications/:id/dismiss.ts b/api/api/v1/notifications/:id/dismiss.ts index b474fc01..7f007c07 100644 --- a/api/api/v1/notifications/:id/dismiss.ts +++ b/api/api/v1/notifications/:id/dismiss.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Notification } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -55,13 +56,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const notification = await Notification.fromId(id); if (!notification) { - return context.json({ error: "Notification not found" }, 404); + throw new ApiError(404, "Notification not found"); } await notification.update({ diff --git a/api/api/v1/notifications/:id/index.ts b/api/api/v1/notifications/:id/index.ts index ed69e7ea..37ca28ec 100644 --- a/api/api/v1/notifications/:id/index.ts +++ b/api/api/v1/notifications/:id/index.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Notification } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -64,13 +65,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const notification = await Notification.fromId(id, user.id); if (!notification) { - return context.json({ error: "Notification not found" }, 404); + throw new ApiError(404, "Notification not found"); } 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 5a7fa54b..de98ddb5 100644 --- a/api/api/v1/notifications/clear/index.ts +++ b/api/api/v1/notifications/clear/index.ts @@ -1,6 +1,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -42,7 +43,7 @@ export default apiRoute((app) => app.openapi(route, async (context) => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } await user.clearAllNotifications(); diff --git a/api/api/v1/notifications/destroy_multiple/index.ts b/api/api/v1/notifications/destroy_multiple/index.ts index a8fdf10b..5d938a03 100644 --- a/api/api/v1/notifications/destroy_multiple/index.ts +++ b/api/api/v1/notifications/destroy_multiple/index.ts @@ -2,6 +2,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -53,7 +54,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { "ids[]": ids } = context.req.valid("query"); diff --git a/api/api/v1/notifications/index.ts b/api/api/v1/notifications/index.ts index 4b179b30..58b8fd69 100644 --- a/api/api/v1/notifications/index.ts +++ b/api/api/v1/notifications/index.ts @@ -4,6 +4,7 @@ import { Notification, Timeline } from "@versia/kit/db"; import { Notifications, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -121,7 +122,7 @@ export default apiRoute((app) => app.openapi(route, async (context) => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { diff --git a/api/api/v1/profile/avatar.ts b/api/api/v1/profile/avatar.ts index 0f938b84..6b583f8a 100644 --- a/api/api/v1/profile/avatar.ts +++ b/api/api/v1/profile/avatar.ts @@ -2,6 +2,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -48,7 +49,7 @@ export default apiRoute((app) => const { user: self } = context.get("auth"); if (!self) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } await self.update({ diff --git a/api/api/v1/profile/header.ts b/api/api/v1/profile/header.ts index afb6196a..9ad0c616 100644 --- a/api/api/v1/profile/header.ts +++ b/api/api/v1/profile/header.ts @@ -2,6 +2,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -48,7 +49,7 @@ export default apiRoute((app) => const { user: self } = context.get("auth"); if (!self) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } await self.update({ diff --git a/api/api/v1/roles/:id/index.test.ts b/api/api/v1/roles/:id/index.test.ts index 34c873b1..2bcb6bb2 100644 --- a/api/api/v1/roles/:id/index.test.ts +++ b/api/api/v1/roles/:id/index.test.ts @@ -144,7 +144,9 @@ describe("/api/v1/roles/:id", () => { expect(response.status).toBe(403); const output = await response.json(); expect(output).toMatchObject({ - error: `Cannot edit role 'higherPriorityRole' with priority 3: your highest role priority is 2`, + error: "Forbidden", + details: + "User with highest role priority 2 cannot edit role with priority 3", }); }); @@ -163,7 +165,8 @@ describe("/api/v1/roles/:id", () => { expect(response.status).toBe(403); const output = await response.json(); expect(output).toMatchObject({ - error: "You cannot add or remove the following permissions you do not yourself have: impersonate", + error: "Forbidden", + details: "User cannot add or remove permissions they do not have", }); }); @@ -226,7 +229,9 @@ describe("/api/v1/roles/:id", () => { expect(response.status).toBe(403); const output = await response.json(); expect(output).toMatchObject({ - error: `Cannot delete role 'higherPriorityRole' with priority 3: your highest role priority is 2`, + error: "Forbidden", + details: + "User with highest role priority 2 cannot delete role with priority 3", }); }); }); diff --git a/api/api/v1/roles/:id/index.ts b/api/api/v1/roles/:id/index.ts index 2627f8ea..19d741fb 100644 --- a/api/api/v1/roles/:id/index.ts +++ b/api/api/v1/roles/:id/index.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -156,13 +157,13 @@ export default apiRoute((app) => { const { id } = context.req.valid("param"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const role = await Role.fromId(id); if (!role) { - return context.json({ error: "Role not found" }, 404); + throw new ApiError(404, "Role not found"); } return context.json(role.toApi(), 200); @@ -175,13 +176,13 @@ export default apiRoute((app) => { context.req.valid("json"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const role = await Role.fromId(id); if (!role) { - return context.json({ error: "Role not found" }, 404); + throw new ApiError(404, "Role not found"); } // Priority check @@ -192,11 +193,10 @@ export default apiRoute((app) => { ); if (role.data.priority > userHighestRole.data.priority) { - return context.json( - { - error: `Cannot edit role '${role.data.name}' with priority ${role.data.priority}: your highest role priority is ${userHighestRole.data.priority}`, - }, + throw new ApiError( 403, + "Forbidden", + `User with highest role priority ${userHighestRole.data.priority} cannot edit role with priority ${role.data.priority}`, ); } @@ -208,11 +208,10 @@ export default apiRoute((app) => { ).every((p) => userPermissions.includes(p)); if (!hasPermissions) { - return context.json( - { - error: `You cannot add or remove the following permissions you do not yourself have: ${permissions.join(", ")}`, - }, + throw new ApiError( 403, + "Forbidden", + "User cannot add or remove permissions they do not have", ); } } @@ -234,13 +233,13 @@ export default apiRoute((app) => { const { id } = context.req.valid("param"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const role = await Role.fromId(id); if (!role) { - return context.json({ error: "Role not found" }, 404); + throw new ApiError(404, "Role not found"); } // Priority check @@ -251,11 +250,10 @@ export default apiRoute((app) => { ); if (role.data.priority > userHighestRole.data.priority) { - return context.json( - { - error: `Cannot delete role '${role.data.name}' with priority ${role.data.priority}: your highest role priority is ${userHighestRole.data.priority}`, - }, + throw new ApiError( 403, + "Forbidden", + `User with highest role priority ${userHighestRole.data.priority} cannot delete role with priority ${role.data.priority}`, ); } diff --git a/api/api/v1/roles/index.test.ts b/api/api/v1/roles/index.test.ts index 912a80d5..e487f1c7 100644 --- a/api/api/v1/roles/index.test.ts +++ b/api/api/v1/roles/index.test.ts @@ -126,7 +126,7 @@ describe(meta.route, () => { expect(response.status).toBe(403); const output = await response.json(); expect(output).toMatchObject({ - error: "You cannot create a role with higher priority than your own", + error: "Cannot create role with higher priority than your own", }); }); @@ -150,7 +150,8 @@ describe(meta.route, () => { expect(response.status).toBe(403); const output = await response.json(); expect(output).toMatchObject({ - error: "You cannot create a role with the following permissions you do not yourself have: impersonate", + error: "Cannot create role with permissions you do not have", + details: "Forbidden permissions: impersonate", }); }); }); diff --git a/api/api/v1/roles/index.ts b/api/api/v1/roles/index.ts index 8e63a57d..592bce36 100644 --- a/api/api/v1/roles/index.ts +++ b/api/api/v1/roles/index.ts @@ -1,6 +1,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "@versia/kit/db"; +import { ApiError } from "~/classes/errors/api-error"; import { RolePermissions } from "~/drizzle/schema"; import { ErrorSchema } from "~/types/api"; @@ -96,7 +97,7 @@ export default apiRoute((app) => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const roles = await Role.getAll(); @@ -113,7 +114,7 @@ export default apiRoute((app) => { context.req.valid("json"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } // Priority check @@ -124,11 +125,9 @@ export default apiRoute((app) => { ); if (priority > userHighestRole.data.priority) { - return context.json( - { - error: "You cannot create a role with higher priority than your own", - }, + throw new ApiError( 403, + "Cannot create role with higher priority than your own", ); } @@ -140,11 +139,10 @@ export default apiRoute((app) => { ).every((p) => userPermissions.includes(p)); if (!hasPermissions) { - return context.json( - { - error: `You cannot create a role with the following permissions you do not yourself have: ${permissions.join(", ")}`, - }, + throw new ApiError( 403, + "Cannot create role with permissions you do not have", + `Forbidden permissions: ${permissions.join(", ")}`, ); } } diff --git a/api/api/v1/statuses/:id/context.ts b/api/api/v1/statuses/:id/context.ts index e8149c84..ffd064be 100644 --- a/api/api/v1/statuses/:id/context.ts +++ b/api/api/v1/statuses/:id/context.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -65,7 +66,7 @@ export default apiRoute((app) => const foundStatus = await Note.fromId(id, user?.id); if (!foundStatus) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } const ancestors = await foundStatus.getAncestors(user ?? null); diff --git a/api/api/v1/statuses/:id/favourite.ts b/api/api/v1/statuses/:id/favourite.ts index 7809b3b9..c0fd1f38 100644 --- a/api/api/v1/statuses/:id/favourite.ts +++ b/api/api/v1/statuses/:id/favourite.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -68,13 +69,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const note = await Note.fromId(id, user?.id); if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } await user.like(note); diff --git a/api/api/v1/statuses/:id/favourited_by.ts b/api/api/v1/statuses/:id/favourited_by.ts index 7d5b239d..70300645 100644 --- a/api/api/v1/statuses/:id/favourited_by.ts +++ b/api/api/v1/statuses/:id/favourited_by.ts @@ -4,6 +4,7 @@ import { Note, Timeline, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -77,13 +78,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const note = await Note.fromId(id, user?.id); if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } const { objects, link } = await Timeline.getUserTimeline( diff --git a/api/api/v1/statuses/:id/index.ts b/api/api/v1/statuses/:id/index.ts index 622ee4ea..8bd4e4fa 100644 --- a/api/api/v1/statuses/:id/index.ts +++ b/api/api/v1/statuses/:id/index.ts @@ -4,6 +4,7 @@ import { Attachment, Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import ISO6391 from "iso-639-1"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -216,7 +217,7 @@ export default apiRoute((app) => { const note = await Note.fromId(id, user?.id); if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } return context.json(await note.toApi(user), 200); @@ -229,11 +230,11 @@ export default apiRoute((app) => { const note = await Note.fromId(id, user?.id); if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } if (note.author.id !== user?.id) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } // TODO: Delete and redraft @@ -249,17 +250,17 @@ export default apiRoute((app) => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const note = await Note.fromId(id, user?.id); if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } if (note.author.id !== user.id) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } // TODO: Polls @@ -275,7 +276,10 @@ export default apiRoute((app) => { media_ids.length > 0 ? await Attachment.fromIds(media_ids) : []; if (foundAttachments.length !== media_ids.length) { - return context.json({ error: "Invalid media IDs" }, 422); + throw new ApiError( + 422, + "Some attachments referenced by media_ids not found", + ); } const newNote = await note.updateFromData({ diff --git a/api/api/v1/statuses/:id/pin.ts b/api/api/v1/statuses/:id/pin.ts index bb72bc1e..1c6639ba 100644 --- a/api/api/v1/statuses/:id/pin.ts +++ b/api/api/v1/statuses/:id/pin.ts @@ -4,6 +4,7 @@ import { Note, db } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import type { SQL } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -76,17 +77,17 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const foundStatus = await Note.fromId(id, user?.id); if (!foundStatus) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } if (foundStatus.author.id !== user.id) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } if ( @@ -98,7 +99,7 @@ export default apiRoute((app) => ), }) ) { - return context.json({ error: "Already pinned" }, 422); + throw new ApiError(422, "Already pinned"); } await user.pin(foundStatus); diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts index 1e4f64da..7aef7578 100644 --- a/api/api/v1/statuses/:id/reblog.ts +++ b/api/api/v1/statuses/:id/reblog.ts @@ -4,6 +4,7 @@ import { Note } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -101,13 +102,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const note = await Note.fromId(id, user.id); if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } const existingReblog = await Note.fromSql( @@ -115,7 +116,7 @@ export default apiRoute((app) => ); if (existingReblog) { - return context.json({ error: "Already reblogged" }, 422); + throw new ApiError(422, "Already reblogged"); } const newReblog = await Note.insert({ @@ -127,14 +128,10 @@ export default apiRoute((app) => applicationId: null, }); - if (!newReblog) { - return context.json({ error: "Failed to reblog" }, 500); - } - const finalNewReblog = await Note.fromId(newReblog.id, user?.id); if (!finalNewReblog) { - return context.json({ error: "Failed to reblog" }, 500); + throw new ApiError(500, "Failed to reblog"); } if (note.author.isLocal() && user.isLocal()) { diff --git a/api/api/v1/statuses/:id/reblogged_by.ts b/api/api/v1/statuses/:id/reblogged_by.ts index b571f64e..eb59b13f 100644 --- a/api/api/v1/statuses/:id/reblogged_by.ts +++ b/api/api/v1/statuses/:id/reblogged_by.ts @@ -4,6 +4,7 @@ import { Note, Timeline, User } from "@versia/kit/db"; import { RolePermissions, Users } from "@versia/kit/tables"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -76,13 +77,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const note = await Note.fromId(id, user.id); if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } const { objects, link } = await Timeline.getUserTimeline( diff --git a/api/api/v1/statuses/:id/source.ts b/api/api/v1/statuses/:id/source.ts index 9fa37d2d..c5959a44 100644 --- a/api/api/v1/statuses/:id/source.ts +++ b/api/api/v1/statuses/:id/source.ts @@ -4,6 +4,7 @@ import type { StatusSource as ApiStatusSource } from "@versia/client/types"; import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -72,13 +73,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const note = await Note.fromId(id, user.id); if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } return context.json( diff --git a/api/api/v1/statuses/:id/unfavourite.ts b/api/api/v1/statuses/:id/unfavourite.ts index 52bcd6d6..1b280da4 100644 --- a/api/api/v1/statuses/:id/unfavourite.ts +++ b/api/api/v1/statuses/:id/unfavourite.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -67,13 +68,13 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const note = await Note.fromId(id, user.id); if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } await user.unlike(note); diff --git a/api/api/v1/statuses/:id/unpin.ts b/api/api/v1/statuses/:id/unpin.ts index e609d5b8..f3065c8d 100644 --- a/api/api/v1/statuses/:id/unpin.ts +++ b/api/api/v1/statuses/:id/unpin.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -67,23 +68,23 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const status = await Note.fromId(id, user.id); if (!status) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } if (status.author.id !== user.id) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } await user.unpin(status); if (!status) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } return context.json(await status.toApi(user), 200); diff --git a/api/api/v1/statuses/:id/unreblog.ts b/api/api/v1/statuses/:id/unreblog.ts index f51709cb..a76ff92f 100644 --- a/api/api/v1/statuses/:id/unreblog.ts +++ b/api/api/v1/statuses/:id/unreblog.ts @@ -4,6 +4,7 @@ import { Note } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -76,14 +77,14 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const note = await Note.fromId(id, user.id); // Check if user is authorized to view this status (if it's private) if (!(note && (await note?.isViewableByUser(user)))) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } const existingReblog = await Note.fromSql( @@ -93,7 +94,7 @@ export default apiRoute((app) => ); if (!existingReblog) { - return context.json({ error: "Not already reblogged" }, 422); + throw new ApiError(422, "Note already reblogged"); } await existingReblog.delete(); @@ -103,7 +104,7 @@ export default apiRoute((app) => const newNote = await Note.fromId(id, user.id); if (!newNote) { - return context.json({ error: "Record not found" }, 404); + throw new ApiError(404, "Note not found"); } return context.json(await newNote.toApi(user), 200); diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index 55ea51da..36230a65 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -4,6 +4,7 @@ import { Attachment, Note } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import ISO6391 from "iso-639-1"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -152,7 +153,7 @@ export default apiRoute((app) => const { user, application } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { @@ -172,19 +173,22 @@ export default apiRoute((app) => media_ids.length > 0 ? await Attachment.fromIds(media_ids) : []; if (foundAttachments.length !== media_ids.length) { - return context.json({ error: "Invalid media IDs" }, 422); + 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))) { - return context.json( - { error: "Invalid in_reply_to_id (not found)" }, + throw new ApiError( 422, + "Note referenced by in_reply_to_id not found", ); } if (quote_id && !(await Note.fromId(quote_id))) { - return context.json({ error: "Invalid quote_id (not found)" }, 422); + throw new ApiError(422, "Note referenced by quote_id not found"); } const newNote = await Note.fromData({ diff --git a/api/api/v1/timelines/home.ts b/api/api/v1/timelines/home.ts index 134da221..bfc145a0 100644 --- a/api/api/v1/timelines/home.ts +++ b/api/api/v1/timelines/home.ts @@ -4,6 +4,7 @@ import { Note, Timeline } from "@versia/kit/db"; import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -69,7 +70,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const { objects, link } = await Timeline.getNoteTimeline( diff --git a/api/api/v2/filters/:id/index.ts b/api/api/v2/filters/:id/index.ts index f39f418f..84e4d2a7 100644 --- a/api/api/v2/filters/:id/index.ts +++ b/api/api/v2/filters/:id/index.ts @@ -4,6 +4,7 @@ import { db } from "@versia/kit/db"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import { type SQL, and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -200,7 +201,7 @@ export default apiRoute((app) => { const { id } = context.req.valid("param"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const userFilter = await db.query.Filters.findFirst({ @@ -212,7 +213,7 @@ export default apiRoute((app) => { }); if (!userFilter) { - return context.json({ error: "Filter not found" }, 404); + throw new ApiError(404, "Filter not found"); } return context.json( @@ -247,7 +248,7 @@ export default apiRoute((app) => { } = context.req.valid("json"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } await db @@ -336,7 +337,7 @@ export default apiRoute((app) => { const { id } = context.req.valid("param"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } await db diff --git a/api/api/v2/filters/index.ts b/api/api/v2/filters/index.ts index 4775a014..f94a12ea 100644 --- a/api/api/v2/filters/index.ts +++ b/api/api/v2/filters/index.ts @@ -4,6 +4,7 @@ import { db } from "@versia/kit/db"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import type { SQL } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ route: "/api/v2/filters", @@ -136,7 +137,7 @@ export default apiRoute((app) => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const userFilters = await db.query.Filters.findMany({ @@ -178,7 +179,7 @@ export default apiRoute((app) => { } = context.req.valid("json"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } const newFilter = ( diff --git a/api/api/v2/media/index.ts b/api/api/v2/media/index.ts index d384b623..54fea3c3 100644 --- a/api/api/v2/media/index.ts +++ b/api/api/v2/media/index.ts @@ -4,6 +4,7 @@ import { Attachment } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import sharp from "sharp"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { MediaManager } from "~/classes/media/media-manager"; import { config } from "~/packages/config-manager/index.ts"; import { ErrorSchema } from "~/types/api"; @@ -82,11 +83,9 @@ export default apiRoute((app) => const { file, thumbnail, description } = context.req.valid("form"); if (file.size > config.validation.max_media_size) { - return context.json( - { - error: `File too large, max size is ${config.validation.max_media_size} bytes`, - }, + throw new ApiError( 413, + `File too large, max size is ${config.validation.max_media_size} bytes`, ); } @@ -94,7 +93,11 @@ export default apiRoute((app) => config.validation.enforce_mime_types && !config.validation.allowed_mime_types.includes(file.type) ) { - return context.json({ error: "Invalid file type" }, 415); + throw new ApiError( + 415, + `File type ${file.type} is not allowed`, + `Allowed types: ${config.validation.allowed_mime_types.join(", ")}`, + ); } const sha256 = new Bun.SHA256(); diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts index ac1ae5f5..765b9105 100644 --- a/api/api/v2/search/index.ts +++ b/api/api/v2/search/index.ts @@ -10,6 +10,7 @@ import { Note, User, db } from "@versia/kit/db"; import { Instances, Notes, RolePermissions, Users } from "@versia/kit/tables"; import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { searchManager } from "~/classes/search/search-manager"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -95,19 +96,14 @@ export default apiRoute((app) => context.req.valid("query"); if (!self && (resolve || offset)) { - return context.json( - { - error: "Cannot use resolve or offset without being authenticated", - }, + throw new ApiError( 401, + "Usage of resolve or offset requires authentication", ); } if (!config.sonic.enabled) { - return context.json( - { error: "Search is not enabled on this server" }, - 501, - ); + throw new ApiError(501, "Search is not enabled on this server"); } let accountResults: string[] = []; diff --git a/api/media/:hash/:name/index.ts b/api/media/:hash/:name/index.ts index 2d8acc3f..83b3d0ac 100644 --- a/api/media/:hash/:name/index.ts +++ b/api/media/:hash/:name/index.ts @@ -1,6 +1,7 @@ import { apiRoute, applyConfig } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -72,7 +73,7 @@ export default apiRoute((app) => const buffer = await file.arrayBuffer(); if (!(await file.exists())) { - return context.json({ error: "File not found" }, 404); + throw new ApiError(404, "File not found"); } // Can't directly copy file into Response because this crashes Bun for now diff --git a/api/media/proxy/:id.ts b/api/media/proxy/:id.ts index 9158cb58..730456c4 100644 --- a/api/media/proxy/:id.ts +++ b/api/media/proxy/:id.ts @@ -2,6 +2,7 @@ import { apiRoute, applyConfig } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -57,9 +58,10 @@ export default apiRoute((app) => // Check if URL is valid if (!URL.canParse(id)) { - return context.json( - { error: "Invalid URL (it should be encoded as base64url" }, + throw new ApiError( 400, + "Invalid URL", + "Should be encoded as base64url", ); } diff --git a/api/objects/:id/index.ts b/api/objects/:id/index.ts index e1e196d1..99ae7476 100644 --- a/api/objects/:id/index.ts +++ b/api/objects/:id/index.ts @@ -8,6 +8,7 @@ import { Like, Note, User } from "@versia/kit/db"; import { Likes, Notes } from "@versia/kit/tables"; import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema, type KnownEntity } from "~/types/api"; @@ -82,7 +83,7 @@ export default apiRoute((app) => if (foundObject) { if (!(await foundObject.isViewableByUser(null))) { - return context.json({ error: "Object not found" }, 404); + throw new ApiError(404, "Object not found"); } } else { foundObject = await Like.fromSql( @@ -98,18 +99,15 @@ export default apiRoute((app) => } if (!(foundObject && apiObject)) { - return context.json({ error: "Object not found" }, 404); + throw new ApiError(404, "Object not found"); } if (!foundAuthor) { - return context.json({ error: "Author not found" }, 404); + throw new ApiError(404, "Author not found"); } if (foundAuthor?.isRemote()) { - return context.json( - { error: "Cannot view objects from remote instances" }, - 403, - ); + throw new ApiError(403, "Object is from a remote instance"); } // If base_url uses https and request uses http, rewrite request to use https // This fixes reverse proxy errors diff --git a/api/users/:uuid/index.ts b/api/users/:uuid/index.ts index 4a3c1d41..ea6f727b 100644 --- a/api/users/:uuid/index.ts +++ b/api/users/:uuid/index.ts @@ -3,6 +3,7 @@ import { createRoute } from "@hono/zod-openapi"; import { User as UserSchema } from "@versia/federation/schemas"; import { User } from "@versia/kit/db"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -68,14 +69,11 @@ export default apiRoute((app) => const user = await User.fromId(uuid); if (!user) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } if (user.isRemote()) { - return context.json( - { error: "Cannot view users from remote instances" }, - 403, - ); + throw new ApiError(403, "User is not on this instance"); } // Try to detect a web browser and redirect to the user's profile page diff --git a/api/users/:uuid/outbox/index.ts b/api/users/:uuid/outbox/index.ts index 5bc1eb0c..89d2bfd5 100644 --- a/api/users/:uuid/outbox/index.ts +++ b/api/users/:uuid/outbox/index.ts @@ -8,6 +8,7 @@ import { Note, User, db } from "@versia/kit/db"; import { Notes } from "@versia/kit/tables"; import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -78,14 +79,11 @@ export default apiRoute((app) => const author = await User.fromId(uuid); if (!author) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } if (author.isRemote()) { - return context.json( - { error: "Cannot view users from remote instances" }, - 403, - ); + throw new ApiError(403, "User is not on this instance"); } const pageNumber = Number(context.req.valid("query").page) || 1; diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts index dae633c2..211119d2 100644 --- a/api/well-known/webfinger/index.ts +++ b/api/well-known/webfinger/index.ts @@ -1,4 +1,10 @@ -import { apiRoute, applyConfig, idValidator, webfingerMention } from "@/api"; +import { + apiRoute, + applyConfig, + idValidator, + parseUserAddress, + webfingerMention, +} from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import type { ResponseError } from "@versia/federation"; @@ -7,6 +13,7 @@ import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; import { ErrorSchema } from "~/types/api"; @@ -71,25 +78,27 @@ export default apiRoute((app) => const host = new URL(config.http.base_url).host; + const { username, domain } = parseUserAddress(requestedUser); + // Check if user is a local user - if (requestedUser.split("@")[1] !== host) { - return context.json({ error: "User is a remote user" }, 404); + if (domain !== host) { + throw new ApiError( + 404, + `User domain ${domain} does not match ${host}`, + ); } - const isUuid = requestedUser.split("@")[0].match(idValidator); + const isUuid = username.match(idValidator); const user = await User.fromSql( and( - eq( - isUuid ? Users.id : Users.username, - requestedUser.split("@")[0], - ), + eq(isUuid ? Users.id : Users.username, username), isNull(Users.instanceId), ), ); if (!user) { - return context.json({ error: "User not found" }, 404); + throw new ApiError(404, "User not found"); } let activityPubUrl = ""; diff --git a/app.ts b/app.ts index 822c3272..ba5a6a18 100644 --- a/app.ts +++ b/app.ts @@ -14,6 +14,7 @@ import { prettyJSON } from "hono/pretty-json"; import { secureHeaders } from "hono/secure-headers"; import pkg from "~/package.json" with { type: "application/json" }; import { config } from "~/packages/config-manager/index.ts"; +import { ApiError } from "./classes/errors/api-error.ts"; import { PluginLoader } from "./classes/plugin/loader.ts"; import { agentBans } from "./middlewares/agent-bans.ts"; import { bait } from "./middlewares/bait.ts"; @@ -192,11 +193,9 @@ export const appFactory = async (): Promise> => { proxy?.headers.set("Cache-Control", "max-age=31536000"); if (!proxy || proxy.status === 404) { - return context.json( - { - error: "Route not found on proxy or API route. Are you using the correct HTTP method?", - }, + throw new ApiError( 404, + "Route not found on proxy or API route. Are you using the correct HTTP method?", ); } @@ -214,6 +213,16 @@ export const appFactory = async (): Promise> => { }); app.onError((error, c) => { + if (error instanceof ApiError) { + return c.json( + { + error: error.message, + details: error.details, + }, + error.status, + ); + } + serverLogger.error`${error}`; sentry?.captureException(error); return c.json( diff --git a/classes/database/instance.ts b/classes/database/instance.ts index 5f75c3ae..d03e6c28 100644 --- a/classes/database/instance.ts +++ b/classes/database/instance.ts @@ -1,9 +1,5 @@ import { getLogger } from "@logtape/logtape"; -import { - EntityValidator, - type ResponseError, - type ValidationError, -} from "@versia/federation"; +import { EntityValidator, type ResponseError } from "@versia/federation"; import type { InstanceMetadata } from "@versia/federation/types"; import { db } from "@versia/kit/db"; import { Instances } from "@versia/kit/tables"; @@ -17,6 +13,7 @@ import { inArray, } from "drizzle-orm"; import { config } from "~/packages/config-manager/index.ts"; +import { ApiError } from "../errors/api-error.ts"; import { BaseInterface } from "./base.ts"; import { User } from "./user.ts"; @@ -141,55 +138,48 @@ export class Instance extends BaseInterface { public static async fetchMetadata(url: string): Promise<{ metadata: InstanceMetadata; protocol: "versia" | "activitypub"; - } | null> { + }> { const origin = new URL(url).origin; const wellKnownUrl = new URL("/.well-known/versia", origin); - const logger = getLogger(["federation", "resolvers"]); const requester = await User.getFederationRequester(); - try { - const { ok, raw, data } = await requester - .get(wellKnownUrl, { - // @ts-expect-error Bun extension - proxy: config.http.proxy.address, - }) - .catch((e) => ({ - ...(e as ResponseError).response, - })); + const { ok, raw, data } = await requester + .get(wellKnownUrl, { + // @ts-expect-error Bun extension + proxy: config.http.proxy.address, + }) + .catch((e) => ({ + ...(e as ResponseError).response, + })); - if (!(ok && raw.headers.get("content-type")?.includes("json"))) { - // If the server doesn't have a Versia well-known endpoint, it's not a Versia instance - // Try to resolve ActivityPub metadata instead - const data = await Instance.fetchActivityPubMetadata(url); + if (!(ok && raw.headers.get("content-type")?.includes("json"))) { + // If the server doesn't have a Versia well-known endpoint, it's not a Versia instance + // Try to resolve ActivityPub metadata instead + const data = await Instance.fetchActivityPubMetadata(url); - if (!data) { - return null; - } - - return { - metadata: data, - protocol: "activitypub", - }; - } - - try { - const metadata = await new EntityValidator().InstanceMetadata( - data, + if (!data) { + throw new ApiError( + 404, + `Instance at ${origin} is not reachable or does not exist`, ); - - return { metadata, protocol: "versia" }; - } catch (error) { - logger.error`Instance ${chalk.bold( - origin, - )} has invalid metadata: ${(error as ValidationError).message}`; - return null; } - } catch (error) { - logger.error`Failed to fetch Versia metadata for instance ${chalk.bold( - origin, - )} - Error! ${error}`; - return null; + + return { + metadata: data, + protocol: "activitypub", + }; + } + + try { + const metadata = await new EntityValidator().InstanceMetadata(data); + + return { metadata, protocol: "versia" }; + } catch { + throw new ApiError( + 404, + `Instance at ${origin} has invalid metadata`, + ); } } @@ -319,7 +309,7 @@ export class Instance extends BaseInterface { } } - public static resolveFromHost(host: string): Promise { + public static resolveFromHost(host: string): Promise { if (host.startsWith("http")) { const url = new URL(host).host; @@ -331,8 +321,7 @@ export class Instance extends BaseInterface { return Instance.resolve(url.origin); } - public static async resolve(url: string): Promise { - const logger = getLogger(["federation", "resolvers"]); + public static async resolve(url: string): Promise { const host = new URL(url).host; const existingInstance = await Instance.fromSql( @@ -345,11 +334,6 @@ export class Instance extends BaseInterface { const output = await Instance.fetchMetadata(url); - if (!output) { - logger.error`Failed to resolve instance ${chalk.bold(host)}`; - return null; - } - const { metadata, protocol } = output; return Instance.insert({ diff --git a/classes/database/user.ts b/classes/database/user.ts index 9a92c178..6bcac35f 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -297,7 +297,7 @@ export class User extends BaseInterface { public async unfollow( followee: User, relationship: Relationship, - ): Promise { + ): Promise { if (followee.isRemote()) { await deliveryQueue.add(DeliveryJobType.FederateEntity, { entity: this.unfollowToVersia(followee), @@ -309,8 +309,6 @@ export class User extends BaseInterface { await relationship.update({ following: false, }); - - return true; } private unfollowToVersia(followee: User): Unfollow { diff --git a/classes/errors/api-error.ts b/classes/errors/api-error.ts new file mode 100644 index 00000000..e6a9ef52 --- /dev/null +++ b/classes/errors/api-error.ts @@ -0,0 +1,24 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import type { JSONObject } from "hono/utils/types"; + +/** + * API Error + * + * Custom error class used to throw errors in the API. Includes a status code, a message and an optional description. + * @extends Error + */ +export class ApiError extends Error { + /** + * @param {StatusCode} status - The status code of the error + * @param {string} message - The message of the error + * @param {string | JSONObject} [details] - The description of the error + */ + public constructor( + public status: ContentfulStatusCode, + public message: string, + public details?: string | JSONObject, + ) { + super(message); + this.name = "ApiError"; + } +} diff --git a/middlewares/agent-bans.ts b/middlewares/agent-bans.ts index 9baf5ce4..356200d5 100644 --- a/middlewares/agent-bans.ts +++ b/middlewares/agent-bans.ts @@ -1,4 +1,5 @@ import { createMiddleware } from "hono/factory"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; export const agentBans = createMiddleware(async (context, next) => { @@ -7,7 +8,7 @@ export const agentBans = createMiddleware(async (context, next) => { for (const agent of config.http.banned_user_agents) { if (new RegExp(agent).test(ua)) { - return context.json({ error: "Forbidden" }, 403); + throw new ApiError(403, "Forbidden"); } } diff --git a/middlewares/boundary-check.ts b/middlewares/boundary-check.ts index b994360e..3c664bd2 100644 --- a/middlewares/boundary-check.ts +++ b/middlewares/boundary-check.ts @@ -1,4 +1,5 @@ import { createMiddleware } from "hono/factory"; +import { ApiError } from "~/classes/errors/api-error"; export const boundaryCheck = createMiddleware(async (context, next) => { // Checks that FormData boundary is present @@ -6,11 +7,10 @@ export const boundaryCheck = createMiddleware(async (context, next) => { if (contentType?.includes("multipart/form-data")) { if (!contentType.includes("boundary")) { - return context.json( - { - error: "You are sending a request with a multipart/form-data content type but without a boundary. Please include a boundary in the Content-Type header. For more information, visit https://stackoverflow.com/questions/3508338/what-is-the-boundary-in-multipart-form-data", - }, + throw new ApiError( 400, + "Missing FormData boundary", + "You are sending a request with a multipart/form-data content type but without a boundary. Please include a boundary in the Content-Type header. For more information, visit https://stackoverflow.com/questions/3508338/what-is-the-boundary-in-multipart-form-data", ); } } diff --git a/middlewares/ip-bans.ts b/middlewares/ip-bans.ts index 9fbc2a5b..c969a02d 100644 --- a/middlewares/ip-bans.ts +++ b/middlewares/ip-bans.ts @@ -3,6 +3,7 @@ import { getLogger } from "@logtape/logtape"; import type { SocketAddress } from "bun"; import { createMiddleware } from "hono/factory"; import { matches } from "ip-matching"; +import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/packages/config-manager"; export const ipBans = createMiddleware(async (context, next) => { @@ -18,7 +19,7 @@ export const ipBans = createMiddleware(async (context, next) => { for (const ip of config.http.banned_ips) { try { if (matches(ip, requestIp?.address)) { - return context.json({ error: "Forbidden" }, 403); + throw new ApiError(403, "Forbidden"); } } catch (e) { const logger = getLogger("server"); diff --git a/plugins/openid/index.ts b/plugins/openid/index.ts index bf29ef52..94d4345e 100644 --- a/plugins/openid/index.ts +++ b/plugins/openid/index.ts @@ -5,6 +5,7 @@ import { getCookie } from "hono/cookie"; import { jwtVerify } from "jose"; import { JOSEError, JWTExpired } from "jose/errors"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error.ts"; import { RolePermissions } from "~/drizzle/schema.ts"; import authorizeRoute from "./routes/authorize.ts"; import jwksRoute from "./routes/jwks.ts"; @@ -108,13 +109,7 @@ plugin.registerRoute("/admin/*", (app) => { const jwtCookie = getCookie(context, "jwt"); if (!jwtCookie) { - return context.json( - { - error: "Unauthorized", - message: "No JWT cookie provided", - }, - 401, - ); + throw new ApiError(401, "Missing JWT cookie"); } const { keys } = context.get("pluginConfig"); @@ -132,22 +127,10 @@ plugin.registerRoute("/admin/*", (app) => { if (result instanceof JOSEError) { if (result instanceof JWTExpired) { - return context.json( - { - error: "Unauthorized", - message: "JWT has expired. Please log in again.", - }, - 401, - ); + throw new ApiError(401, "JWT has expired"); } - return context.json( - { - error: "Unauthorized", - message: "Invalid JWT", - }, - 401, - ); + throw new ApiError(401, "Invalid JWT"); } const { @@ -155,25 +138,15 @@ plugin.registerRoute("/admin/*", (app) => { } = result; if (!sub) { - return context.json( - { - error: "Unauthorized", - message: "Invalid JWT (no sub)", - }, - 401, - ); + throw new ApiError(401, "Invalid JWT (no sub)"); } const user = await User.fromId(sub); if (!user?.hasPermission(RolePermissions.ManageInstanceFederation)) { - return context.json( - { - error: "Unauthorized", - message: - "You do not have permission to access this resource", - }, + throw new ApiError( 403, + `Missing '${RolePermissions.ManageInstanceFederation}' permission`, ); } diff --git a/plugins/openid/routes/authorize.test.ts b/plugins/openid/routes/authorize.test.ts index 3a31c0f4..d71ba7bd 100644 --- a/plugins/openid/routes/authorize.test.ts +++ b/plugins/openid/routes/authorize.test.ts @@ -102,7 +102,7 @@ describe("/oauth/authorize", () => { const params = new URLSearchParams(location.search); expect(params.get("error")).toBe("invalid_request"); expect(params.get("error_description")).toBe( - "Invalid JWT, could not verify", + "Invalid JWT: could not verify", ); }); @@ -141,7 +141,7 @@ describe("/oauth/authorize", () => { const params = new URLSearchParams(location.search); expect(params.get("error")).toBe("invalid_request"); expect(params.get("error_description")).toBe( - "Invalid JWT, missing required fields (aud, sub, exp)", + "Invalid JWT: missing required fields (aud, sub, exp, iss)", ); }); @@ -183,7 +183,7 @@ describe("/oauth/authorize", () => { const params = new URLSearchParams(location.search); expect(params.get("error")).toBe("invalid_request"); expect(params.get("error_description")).toBe( - "Invalid JWT, sub is not a valid user ID", + "Invalid JWT: sub is not a valid user ID", ); const jwt2 = await new SignJWT({ @@ -266,9 +266,9 @@ describe("/oauth/authorize", () => { config.http.base_url, ); const params = new URLSearchParams(location.search); - expect(params.get("error")).toBe("invalid_request"); + expect(params.get("error")).toBe("unauthorized"); expect(params.get("error_description")).toBe( - `User is missing the required permission ${RolePermissions.OAuth}`, + `User missing required '${RolePermissions.OAuth}' permission`, ); config.permissions.default = oldPermissions; @@ -312,7 +312,7 @@ describe("/oauth/authorize", () => { const params = new URLSearchParams(location.search); expect(params.get("error")).toBe("invalid_request"); expect(params.get("error_description")).toBe( - "Invalid client_id: no associated application found", + "Invalid client_id: no associated API application found", ); }); @@ -354,7 +354,7 @@ describe("/oauth/authorize", () => { const params = new URLSearchParams(location.search); expect(params.get("error")).toBe("invalid_request"); expect(params.get("error_description")).toBe( - "Invalid redirect_uri: does not match application's redirect_uri", + "Invalid redirect_uri: does not match API application's redirect_uri", ); }); @@ -394,7 +394,7 @@ describe("/oauth/authorize", () => { config.http.base_url, ); const params = new URLSearchParams(location.search); - expect(params.get("error")).toBe("invalid_scope"); + expect(params.get("error")).toBe("invalid_request"); expect(params.get("error_description")).toBe( "Invalid scope: not a subset of the application's scopes", ); diff --git a/plugins/openid/routes/oauth/callback.ts b/plugins/openid/routes/oauth/callback.ts index 1e160eed..26e8c301 100644 --- a/plugins/openid/routes/oauth/callback.ts +++ b/plugins/openid/routes/oauth/callback.ts @@ -6,6 +6,7 @@ import { type SQL, and, eq, isNull } from "@versia/kit/drizzle"; import { OpenIdAccounts, RolePermissions, Users } from "@versia/kit/tables"; import { setCookie } from "hono/cookie"; import { SignJWT } from "jose"; +import { ApiError } from "~/classes/errors/api-error.ts"; import type { PluginType } from "../../index.ts"; import { automaticOidcFlow } from "../../utils.ts"; @@ -78,7 +79,7 @@ export default (plugin: PluginType): void => { .providers.find((provider) => provider.id === issuerParam); if (!issuer) { - return context.json({ error: "Issuer not found" }, 404); + throw new ApiError(404, "Issuer not found"); } const userInfo = await automaticOidcFlow( @@ -303,10 +304,7 @@ export default (plugin: PluginType): void => { } if (!flow.application) { - return context.json( - { error: "Application not found" }, - 500, - ); + throw new ApiError(500, "Application not found"); } const code = randomString(32, "hex"); diff --git a/plugins/openid/routes/sso/:id/index.ts b/plugins/openid/routes/sso/:id/index.ts index 4d3dbbe8..64c4c395 100644 --- a/plugins/openid/routes/sso/:id/index.ts +++ b/plugins/openid/routes/sso/:id/index.ts @@ -4,6 +4,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { db } from "@versia/kit/db"; import { type SQL, eq } from "@versia/kit/drizzle"; import { OpenIdAccounts, RolePermissions } from "@versia/kit/tables"; +import { ApiError } from "~/classes/errors/api-error"; import type { PluginType } from "~/plugins/openid"; import { ErrorSchema } from "~/types/api"; @@ -66,12 +67,7 @@ export default (plugin: PluginType): void => { const { user } = context.get("auth"); if (!user) { - return context.json( - { - error: "Unauthorized", - }, - 401, - ); + throw new ApiError(401, "Unauthorized"); } const issuer = context @@ -96,11 +92,9 @@ export default (plugin: PluginType): void => { }); if (!account) { - return context.json( - { - error: "Account not found or is not linked to this issuer", - }, + throw new ApiError( 404, + "Account not found or is not linked to this issuer", ); } @@ -163,7 +157,7 @@ export default (plugin: PluginType): void => { const { user } = context.get("auth"); if (!user) { - return context.json({ error: "Unauthorized" }, 401); + throw new ApiError(401, "Unauthorized"); } // Check if issuer exists @@ -189,11 +183,9 @@ export default (plugin: PluginType): void => { }); if (!account) { - return context.json( - { - error: "Account not found or is not linked to this issuer", - }, + throw new ApiError( 404, + "Account not found or is not linked to this issuer", ); } diff --git a/plugins/openid/routes/sso/index.ts b/plugins/openid/routes/sso/index.ts index 42165e24..6a1a44a8 100644 --- a/plugins/openid/routes/sso/index.ts +++ b/plugins/openid/routes/sso/index.ts @@ -6,6 +6,7 @@ import { generateRandomCodeVerifier, } from "oauth4webapi"; import { z } from "zod"; +import { ApiError } from "~/classes/errors/api-error.ts"; import { ErrorSchema } from "~/types/api"; import type { PluginType } from "../../index.ts"; import { oauthDiscoveryRequest, oauthRedirectUri } from "../../utils.ts"; @@ -57,12 +58,7 @@ export default (plugin: PluginType): void => { const { user } = context.get("auth"); if (!user) { - return context.json( - { - error: "Unauthorized", - }, - 401, - ); + throw new ApiError(401, "Unauthorized"); } const linkedAccounts = await user.getLinkedOidcAccounts( @@ -133,12 +129,7 @@ export default (plugin: PluginType): void => { const { user } = context.get("auth"); if (!user) { - return context.json( - { - error: "Unauthorized", - }, - 401, - ); + throw new ApiError(401, "Unauthorized"); } const { issuer: issuerId } = context.req.valid("json"); diff --git a/tests/api.test.ts b/tests/api.test.ts index d7153de5..60505640 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -25,7 +25,7 @@ describe("API Tests", () => { const data = await response.json(); expect(data.error).toBeString(); - expect(data.error).toContain("https://stackoverflow.com"); + expect(data.details).toContain("https://stackoverflow.com"); }); // Now automatically mitigated by the server @@ -35,7 +35,7 @@ describe("API Tests", () => { } const response = await fakeRequest( - + "/api/v1/instance", base_url.replace("https://", "http://"), ), diff --git a/utils/api.ts b/utils/api.ts index 3842cb1f..18f1a8df 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -24,6 +24,7 @@ import { import { type ParsedQs, parse } from "qs"; import type { 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 "~/packages/config-manager/index.ts"; import type { ApiRouteMetadata, HonoEnv, HttpVerb } from "~/types/api"; @@ -162,7 +163,7 @@ const checkPermissions = ( auth: AuthData | null, permissionData: ApiRouteMetadata["permissions"], context: Context, -): Response | undefined => { +): void => { const userPerms = auth?.user ? auth.user.getAllPermissions() : config.permissions.anonymous; @@ -175,11 +176,10 @@ const checkPermissions = ( const missingPerms = requiredPerms.filter( (perm) => !userPerms.includes(perm), ); - return context.json( - { - error: `You do not have the required permissions to access this route. Missing: ${missingPerms.join(", ")}`, - }, + throw new ApiError( 403, + "Missing permissions", + `Missing: ${missingPerms.join(", ")}`, ); } }; @@ -188,7 +188,7 @@ const checkRouteNeedsAuth = ( auth: AuthData | null, authData: ApiRouteMetadata["auth"], context: Context, -): Response | AuthData => { +): AuthData => { if (auth?.user && auth?.token) { return { user: auth.user, @@ -200,12 +200,7 @@ const checkRouteNeedsAuth = ( authData.required || authData.methodOverrides?.[context.req.method as HttpVerb] ) { - return context.json( - { - error: "This route requires authentication.", - }, - 401, - ); + throw new ApiError(401, "This route requires authentication"); } return { @@ -218,31 +213,25 @@ const checkRouteNeedsAuth = ( export const checkRouteNeedsChallenge = async ( challengeData: ApiRouteMetadata["challenge"], context: Context, -): Promise => { +): Promise => { if (!challengeData) { - return true; + return; } const challengeSolution = context.req.header("X-Challenge-Solution"); if (!challengeSolution) { - return context.json( - { - error: "This route requires a challenge solution to be sent to it via the X-Challenge-Solution header. Please check the documentation for more information.", - }, + throw new ApiError( 401, + "Challenge required", + "This route requires a challenge solution to be sent to it via the X-Challenge-Solution header. Please check the documentation for more information.", ); } const { challenge_id } = extractParams(challengeSolution); if (!challenge_id) { - return context.json( - { - error: "The challenge solution provided is invalid.", - }, - 401, - ); + throw new ApiError(401, "The challenge solution provided is invalid."); } const challenge = await db.query.Challenges.findFirst({ @@ -250,21 +239,11 @@ export const checkRouteNeedsChallenge = async ( }); if (!challenge) { - return context.json( - { - error: "The challenge solution provided is invalid.", - }, - 401, - ); + throw new ApiError(401, "The challenge solution provided is invalid."); } if (new Date(challenge.expiresAt) < new Date()) { - return context.json( - { - error: "The challenge provided has expired.", - }, - 401, - ); + throw new ApiError(401, "The challenge provided has expired."); } const isValid = await verifySolution( @@ -273,11 +252,9 @@ export const checkRouteNeedsChallenge = async ( ); if (!isValid) { - return context.json( - { - error: "The challenge solution provided is incorrect.", - }, + throw new ApiError( 401, + "The challenge solution provided is incorrect.", ); } @@ -286,8 +263,6 @@ export const checkRouteNeedsChallenge = async ( .update(Challenges) .set({ expiresAt: new Date().toISOString() }) .where(eq(Challenges.id, challenge_id)); - - return true; }; export const auth = ( @@ -311,41 +286,19 @@ export const auth = ( user: (await token?.getUser()) ?? null, }; - // Only exists for type casting, as otherwise weird errors happen with Hono - const fakeResponse = context.json({}); - // Authentication check - const authCheck = checkRouteNeedsAuth(auth, authData, context) as - | typeof fakeResponse - | AuthData; - - if (authCheck instanceof Response) { - return authCheck; - } + const authCheck = checkRouteNeedsAuth(auth, authData, context); context.set("auth", authCheck); // Permissions check if (permissionData) { - const permissionCheck = checkPermissions( - auth, - permissionData, - context, - ); - if (permissionCheck) { - return permissionCheck as typeof fakeResponse; - } + checkPermissions(auth, permissionData, context); } // Challenge check if (challengeData && config.validation.challenges.enabled) { - const challengeCheck = await checkRouteNeedsChallenge( - challengeData, - context, - ); - if (challengeCheck !== true) { - return challengeCheck as typeof fakeResponse; - } + await checkRouteNeedsChallenge(challengeData, context); } await next();