refactor(api): ♻️ Throw ApiError instead of returning error JSON

This commit is contained in:
Jesse Wierzbinski 2024-12-30 18:00:23 +01:00
parent c14621ee06
commit fbfd237f27
No known key found for this signature in database
88 changed files with 458 additions and 483 deletions

View file

@ -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({

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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

View file

@ -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);

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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();

View file

@ -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(

View file

@ -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);

View file

@ -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}`,
);
}

View file

@ -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(

View file

@ -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 {

View file

@ -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(

View file

@ -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);
}),

View file

@ -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(

View file

@ -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(

View file

@ -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

View file

@ -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);

View file

@ -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(
throw new ApiError(
422,
`Validation failed: ${errorsText}`,
Object.fromEntries(
Object.entries(errors.details).filter(
([_, errors]) => errors.length > 0,
),
),
},
422,
);
}

View file

@ -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");
}),
);

View file

@ -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(

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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(

View file

@ -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(

View file

@ -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();

View file

@ -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`,
);
}

View file

@ -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}`,
);
}

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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 } =

View file

@ -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 = {

View file

@ -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);

View file

@ -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();

View file

@ -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(

View file

@ -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({

View file

@ -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);

View file

@ -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();

View file

@ -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");

View file

@ -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 {

View file

@ -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({

View file

@ -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({

View file

@ -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",
});
});
});

View file

@ -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}`,
);
}

View file

@ -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",
});
});
});

View file

@ -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(", ")}`,
);
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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(

View file

@ -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({

View file

@ -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);

View file

@ -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()) {

View file

@ -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(

View file

@ -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(

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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({

View file

@ -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(

View file

@ -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

View file

@ -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 = (

View file

@ -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();

View file

@ -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[] = [];

View file

@ -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

View file

@ -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",
);
}

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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 = "";

17
app.ts
View file

@ -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<OpenAPIHono<HonoEnv>> => {
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<OpenAPIHono<HonoEnv>> => {
});
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(

View file

@ -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,14 +138,12 @@ export class Instance extends BaseInterface<typeof Instances> {
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
@ -164,7 +159,10 @@ export class Instance extends BaseInterface<typeof Instances> {
const data = await Instance.fetchActivityPubMetadata(url);
if (!data) {
return null;
throw new ApiError(
404,
`Instance at ${origin} is not reachable or does not exist`,
);
}
return {
@ -174,22 +172,14 @@ export class Instance extends BaseInterface<typeof Instances> {
}
try {
const metadata = await new EntityValidator().InstanceMetadata(
data,
);
const metadata = await new EntityValidator().InstanceMetadata(data);
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;
} catch {
throw new ApiError(
404,
`Instance at ${origin} has invalid metadata`,
);
}
}
@ -319,7 +309,7 @@ export class Instance extends BaseInterface<typeof Instances> {
}
}
public static resolveFromHost(host: string): Promise<Instance | null> {
public static resolveFromHost(host: string): Promise<Instance> {
if (host.startsWith("http")) {
const url = new URL(host).host;
@ -331,8 +321,7 @@ export class Instance extends BaseInterface<typeof Instances> {
return Instance.resolve(url.origin);
}
public static async resolve(url: string): Promise<Instance | null> {
const logger = getLogger(["federation", "resolvers"]);
public static async resolve(url: string): Promise<Instance> {
const host = new URL(url).host;
const existingInstance = await Instance.fromSql(
@ -345,11 +334,6 @@ export class Instance extends BaseInterface<typeof Instances> {
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({

View file

@ -297,7 +297,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
public async unfollow(
followee: User,
relationship: Relationship,
): Promise<boolean> {
): Promise<void> {
if (followee.isRemote()) {
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity: this.unfollowToVersia(followee),
@ -309,8 +309,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
await relationship.update({
following: false,
});
return true;
}
private unfollowToVersia(followee: User): Unfollow {

View file

@ -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";
}
}

View file

@ -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");
}
}

View file

@ -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",
);
}
}

View file

@ -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");

View file

@ -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`,
);
}

View file

@ -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",
);

View file

@ -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");

View file

@ -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",
);
}

View file

@ -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");

View file

@ -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

View file

@ -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<true | Response> => {
): Promise<void> => {
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();