refactor(api): ♻️ Move from @hono/zod-openapi to hono-openapi

hono-openapi is easier to work with and generates better OpenAPI definitions
This commit is contained in:
Jesse Wierzbinski 2025-03-29 03:30:06 +01:00
parent 0576aff972
commit 58342e86e1
No known key found for this signature in database
240 changed files with 9494 additions and 9575 deletions

View file

@ -1,80 +1,16 @@
import { apiRoute } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Application, User } from "@versia/kit/db"; import { Application, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { eq, or } from "drizzle-orm"; import { eq, or } from "drizzle-orm";
import type { Context } from "hono"; import type { Context } from "hono";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { setCookie } from "hono/cookie"; import { setCookie } from "hono/cookie";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const schemas = {
form: z.object({
identifier: z
.string()
.email()
.toLowerCase()
.or(z.string().toLowerCase()),
password: z.string().min(2).max(100),
}),
query: z.object({
scope: z.string().optional(),
redirect_uri: z.string().url().optional(),
response_type: z.enum([
"code",
"token",
"none",
"id_token",
"code id_token",
"code token",
"token id_token",
"code token id_token",
]),
client_id: z.string(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.enum(["plain", "S256"]).optional(),
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
}),
};
const route = createRoute({
method: "post",
path: "/api/auth/login",
summary: "Login",
description: "Login to the application",
request: {
body: {
content: {
"multipart/form-data": {
schema: schemas.form,
},
},
},
query: schemas.query,
},
responses: {
302: {
description: "Redirect to OAuth authorize, or error",
headers: {
"Set-Cookie": {
description: "JWT cookie",
required: false,
},
},
},
},
});
const returnError = ( const returnError = (
context: Context, context: Context,
error: string, error: string,
@ -101,126 +37,194 @@ const returnError = (
}; };
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.post(
const oidcConfig = config.plugins?.config?.["@versia/openid"] as "/api/auth/login",
| { describeRoute({
forced: boolean; summary: "Login",
providers: { description: "Login to the application",
id: string; responses: {
name: string; 302: {
icon: string; description: "Redirect to OAuth authorize, or error",
}[]; headers: {
keys: { "Set-Cookie": {
private: string; description: "JWT cookie",
public: string; required: false,
}; },
}
| undefined;
if (!oidcConfig) {
return returnError(
context,
"invalid_request",
"The OpenID Connect plugin is not enabled on this instance. Cannot process login request.",
);
}
if (oidcConfig?.forced) {
return returnError(
context,
"invalid_request",
"Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
);
}
const { identifier, password } = context.req.valid("form");
const { client_id } = context.req.valid("query");
// Find user
const user = await User.fromSql(
or(
eq(Users.email, identifier.toLowerCase()),
eq(Users.username, identifier.toLowerCase()),
),
);
if (
!(
user &&
(await Bun.password.verify(password, user.data.password || ""))
)
) {
return returnError(
context,
"invalid_grant",
"Invalid identifier or password",
);
}
if (user.data.passwordResetToken) {
return context.redirect(
`${config.frontend.routes.password_reset}?${new URLSearchParams(
{
token: user.data.passwordResetToken ?? "",
login_reset: "true",
}, },
).toString()}`, },
); },
} }),
validator(
"query",
z.object({
scope: z.string().optional(),
redirect_uri: z.string().url().optional(),
response_type: z.enum([
"code",
"token",
"none",
"id_token",
"code id_token",
"code token",
"token id_token",
"code token id_token",
]),
client_id: z.string(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.enum(["plain", "S256"]).optional(),
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
}),
handleZodError,
),
validator(
"form",
z.object({
identifier: z
.string()
.email()
.toLowerCase()
.or(z.string().toLowerCase()),
password: z.string().min(2).max(100),
}),
handleZodError,
),
async (context) => {
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
| {
forced: boolean;
providers: {
id: string;
name: string;
icon: string;
}[];
keys: {
private: string;
public: string;
};
}
| undefined;
// Try and import the key if (!oidcConfig) {
const privateKey = await crypto.subtle.importKey( return returnError(
"pkcs8", context,
Buffer.from(oidcConfig?.keys?.private ?? "", "base64"), "invalid_request",
"Ed25519", "The OpenID Connect plugin is not enabled on this instance. Cannot process login request.",
false, );
["sign"],
);
// Generate JWT
const jwt = await new SignJWT({
sub: user.id,
iss: config.http.base_url.origin,
aud: client_id,
exp: Math.floor(Date.now() / 1000) + 60 * 60,
iat: Math.floor(Date.now() / 1000),
nbf: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey);
const application = await Application.fromClientId(client_id);
if (!application) {
throw new ApiError(400, "Invalid application");
}
const searchParams = new URLSearchParams({
application: application.data.name,
});
if (application.data.website) {
searchParams.append("website", application.data.website);
}
// Add all data that is not undefined except email and password
for (const [key, value] of Object.entries(context.req.query())) {
if (key !== "email" && key !== "password" && value !== undefined) {
searchParams.append(key, String(value));
} }
}
// Redirect to OAuth authorize with JWT if (oidcConfig?.forced) {
setCookie(context, "jwt", jwt, { return returnError(
httpOnly: true, context,
secure: true, "invalid_request",
sameSite: "Strict", "Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
path: "/", );
// 2 weeks }
maxAge: 60 * 60 * 24 * 14,
}); const { identifier, password } = context.req.valid("form");
return context.redirect( const { client_id } = context.req.valid("query");
`${config.frontend.routes.consent}?${searchParams.toString()}`,
); // Find user
}), const user = await User.fromSql(
or(
eq(Users.email, identifier.toLowerCase()),
eq(Users.username, identifier.toLowerCase()),
),
);
if (
!(
user &&
(await Bun.password.verify(
password,
user.data.password || "",
))
)
) {
return returnError(
context,
"invalid_grant",
"Invalid identifier or password",
);
}
if (user.data.passwordResetToken) {
return context.redirect(
`${config.frontend.routes.password_reset}?${new URLSearchParams(
{
token: user.data.passwordResetToken ?? "",
login_reset: "true",
},
).toString()}`,
);
}
// Try and import the key
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(oidcConfig?.keys?.private ?? "", "base64"),
"Ed25519",
false,
["sign"],
);
// Generate JWT
const jwt = await new SignJWT({
sub: user.id,
iss: config.http.base_url.origin,
aud: client_id,
exp: Math.floor(Date.now() / 1000) + 60 * 60,
iat: Math.floor(Date.now() / 1000),
nbf: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey);
const application = await Application.fromClientId(client_id);
if (!application) {
throw new ApiError(400, "Invalid application");
}
const searchParams = new URLSearchParams({
application: application.data.name,
});
if (application.data.website) {
searchParams.append("website", application.data.website);
}
// Add all data that is not undefined except email and password
for (const [key, value] of Object.entries(context.req.query())) {
if (
key !== "email" &&
key !== "password" &&
value !== undefined
) {
searchParams.append(key, String(value));
}
}
// Redirect to OAuth authorize with JWT
setCookie(context, "jwt", jwt, {
httpOnly: true,
secure: true,
sameSite: "Strict",
path: "/",
// 2 weeks
maxAge: 60 * 60 * 24 * 14,
});
return context.redirect(
`${config.frontend.routes.consent}?${searchParams.toString()}`,
);
},
),
); );

View file

@ -1,72 +1,76 @@
import { apiRoute } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { Applications, Tokens } from "@versia/kit/tables"; import { Applications, Tokens } from "@versia/kit/tables";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const schemas = {
query: z.object({
redirect_uri: z.string().url(),
client_id: z.string(),
code: z.string(),
}),
};
const route = createRoute({
method: "get",
path: "/api/auth/redirect",
summary: "OAuth Code flow",
description:
"Redirects to the application, or back to login if the code is invalid",
tags: ["OpenID"],
responses: {
302: {
description:
"Redirects to the application, or back to login if the code is invalid",
},
},
request: {
query: schemas.query,
},
});
/** /**
* OAuth Code flow * OAuth Code flow
*/ */
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.get(
const { redirect_uri, client_id, code } = context.req.valid("query"); "/api/auth/redirect",
describeRoute({
summary: "OAuth Code flow",
description:
"Redirects to the application, or back to login if the code is invalid",
tags: ["OpenID"],
responses: {
302: {
description:
"Redirects to the application, or back to login if the code is invalid",
},
},
}),
validator(
"query",
z.object({
redirect_uri: z.string().url(),
client_id: z.string(),
code: z.string(),
}),
handleZodError,
),
async (context) => {
const { redirect_uri, client_id, code } =
context.req.valid("query");
const redirectToLogin = (error: string): Response => const redirectToLogin = (error: string): Response =>
context.redirect( context.redirect(
`${config.frontend.routes.login}?${new URLSearchParams({ `${config.frontend.routes.login}?${new URLSearchParams({
...context.req.query, ...context.req.query,
error: encodeURIComponent(error), error: encodeURIComponent(error),
}).toString()}`,
);
const foundToken = await db
.select()
.from(Tokens)
.leftJoin(
Applications,
eq(Tokens.applicationId, Applications.id),
)
.where(
and(
eq(Tokens.code, code),
eq(Applications.clientId, client_id),
),
)
.limit(1);
if (!foundToken || foundToken.length <= 0) {
return redirectToLogin("Invalid code");
}
// Redirect back to application
return context.redirect(
`${redirect_uri}?${new URLSearchParams({
code,
}).toString()}`, }).toString()}`,
); );
},
const foundToken = await db ),
.select()
.from(Tokens)
.leftJoin(Applications, eq(Tokens.applicationId, Applications.id))
.where(
and(
eq(Tokens.code, code),
eq(Applications.clientId, client_id),
),
)
.limit(1);
if (!foundToken || foundToken.length <= 0) {
return redirectToLogin("Invalid code");
}
// Redirect back to application
return context.redirect(
`${redirect_uri}?${new URLSearchParams({
code,
}).toString()}`,
);
}),
); );

View file

@ -1,42 +1,13 @@
import { apiRoute } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Context } from "hono"; import type { Context } from "hono";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const schemas = {
form: z.object({
token: z.string().min(1),
password: z.string().min(3).max(100),
}),
};
const route = createRoute({
method: "post",
path: "/api/auth/reset",
summary: "Reset password",
description: "Reset password",
responses: {
302: {
description: "Redirect to the password reset page with a message",
},
},
request: {
body: {
content: {
"application/x-www-form-urlencoded": {
schema: schemas.form,
},
"multipart/form-data": {
schema: schemas.form,
},
},
},
},
});
const returnError = ( const returnError = (
context: Context, context: Context,
token: string, token: string,
@ -60,27 +31,50 @@ const returnError = (
}; };
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.post(
const { token, password } = context.req.valid("form"); "/api/auth/reset",
describeRoute({
summary: "Reset password",
description: "Reset password",
responses: {
302: {
description:
"Redirect to the password reset page with a message",
},
},
}),
validator(
"form",
z.object({
token: z.string().min(1),
password: z.string().min(3).max(100),
}),
handleZodError,
),
async (context) => {
const { token, password } = context.req.valid("form");
const user = await User.fromSql(eq(Users.passwordResetToken, token)); const user = await User.fromSql(
eq(Users.passwordResetToken, token),
if (!user) {
return returnError(
context,
token,
"invalid_token",
"Invalid token",
); );
}
await user.update({ if (!user) {
password: await Bun.password.hash(password), return returnError(
passwordResetToken: null, context,
}); token,
"invalid_token",
"Invalid token",
);
}
return context.redirect( await user.update({
`${config.frontend.routes.password_reset}?success=true`, password: await Bun.password.hash(password),
); passwordResetToken: null,
}), });
return context.redirect(
`${config.frontend.routes.password_reset}?success=true`,
);
},
),
); );

View file

@ -1,71 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/block",
summary: "Block account",
description:
"Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#block",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:blocks"],
permissions: [
RolePermission.ManageOwnBlocks,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
responses: {
200: {
description:
"Successfully blocked, or account was already blocked.",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (!foundRelationship.data.blocking) {
await foundRelationship.update({
blocking: true,
});
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -1,110 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
iso631,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/follow",
summary: "Follow account",
description:
"Follow the given account. Can also be used to update whether to show reblogs or enable notifications.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#follow",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:follows"],
permissions: [
RolePermission.ManageOwnFollows,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
responses: {
200: {
description:
"Successfully followed, or account was already followed",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
403: {
description:
"Trying to follow someone that you block or that blocks you",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
body: {
content: {
"application/json": {
schema: z.object({
reblogs: z.boolean().default(true).openapi({
description:
"Receive this accounts reblogs in home timeline?",
example: true,
}),
notify: z.boolean().default(false).openapi({
description:
"Receive notifications when this account posts a status?",
example: false,
}),
languages: z
.array(iso631)
.default([])
.openapi({
description:
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this accounts posts in all languages.",
example: ["en", "fr"],
}),
}),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const { reblogs, notify, languages } = context.req.valid("json");
const otherUser = context.get("user");
let relationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (!relationship.data.following) {
relationship = await user.followRequest(otherUser, {
reblogs,
notify,
languages,
});
}
return context.json(relationship.toApi(), 200);
}),
);

View file

@ -1,108 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}/followers",
summary: "Get accounts followers",
description:
"Accounts which follow the given account, if network is not hidden by the account owner.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#followers",
},
tags: ["Accounts"],
middleware: [
auth({
auth: false,
scopes: ["read:accounts"],
permissions: [
RolePermission.ViewAccountFollows,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
query: z.object({
max_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: AccountSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.number().int().min(1).max(40).default(20).openapi({
description: "Maximum number of results to return.",
}),
}),
},
responses: {
200: {
description: "Accounts which follow the given account.",
content: {
"application/json": {
schema: z.array(AccountSchema),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({
description: "Links to the next and previous pages",
example:
'<https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/followers?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/followers?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { max_id, since_id, min_id, limit } = context.req.valid("query");
const otherUser = context.get("user");
// TODO: Add follower/following privacy settings
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
),
limit,
new URL(context.req.url),
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
},
);
}),
);

View file

@ -1,109 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}/following",
summary: "Get accounts following",
description:
"Accounts which the given account is following, if network is not hidden by the account owner.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#following",
},
tags: ["Accounts"],
middleware: [
auth({
auth: false,
scopes: ["read:accounts"],
permissions: [
RolePermission.ViewAccountFollows,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
query: z.object({
max_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: AccountSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.number().int().min(1).max(40).default(20).openapi({
description: "Maximum number of results to return.",
}),
}),
},
responses: {
200: {
description: "Accounts which the given account is following.",
content: {
"application/json": {
schema: z.array(AccountSchema),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({
description: "Links to the next and previous pages",
example:
'<https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/following?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/following?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { max_id, since_id, min_id } = context.req.valid("query");
const otherUser = context.get("user");
// TODO: Add follower/following privacy settings
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
),
context.req.valid("query").limit,
new URL(context.req.url),
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
},
);
}),
);

View file

@ -1,50 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}",
summary: "Get account",
description: "View information about a profile.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#get",
},
tags: ["Accounts"],
middleware: [
auth({
auth: false,
permissions: [RolePermission.ViewAccounts],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description:
"The Account record will be returned. Note that acct of local users does not include the domain name.",
content: {
"application/json": {
schema: AccountSchema,
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
return context.json(otherUser.toApi(user?.id === otherUser.id), 200);
}),
);

View file

@ -1,95 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/mute",
summary: "Mute account",
description:
"Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#mute",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:mutes"],
permissions: [
RolePermission.ManageOwnMutes,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
body: {
content: {
"application/json": {
schema: z.object({
notifications: z.boolean().default(true).openapi({
description:
"Mute notifications in addition to statuses?",
}),
duration: z
.number()
.int()
.min(0)
.max(60 * 60 * 24 * 365 * 5)
.default(0)
.openapi({
description:
"How long the mute should last, in seconds.",
}),
}),
},
},
},
},
responses: {
200: {
description:
"Successfully muted, or account was already muted. Note that you can call this API method again with notifications=false to update the relationship so that only statuses are muted.",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
// TODO: Add duration support
const { notifications } = context.req.valid("json");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
// TODO: Implement duration
await foundRelationship.update({
muting: true,
mutingNotifications: notifications,
});
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -1,82 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/note",
summary: "Set private note on profile",
description: "Sets a private note on a user.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#note",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:accounts"],
permissions: [
RolePermission.ManageOwnAccount,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
body: {
content: {
"application/json": {
schema: z.object({
comment: RelationshipSchema.shape.note
.optional()
.openapi({
description:
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
}),
}),
},
},
},
},
responses: {
200: {
description: "Successfully updated profile note",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const { comment } = context.req.valid("json");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
await foundRelationship.update({
note: comment ?? "",
});
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -1,64 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/pin",
summary: "Feature account on your profile",
description:
"Add the given account to the users featured profiles. (Featured profiles are currently shown on the users own public profile.)",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#pin",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:accounts"],
permissions: [
RolePermission.ManageOwnAccount,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
await foundRelationship.update({
endorsed: true,
});
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -1,61 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/refetch",
summary: "Refetch account",
description: "Refetch the given account's profile from the remote server",
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:accounts"],
permissions: [RolePermission.ViewAccounts],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description: "Refetched account data",
content: {
"application/json": {
schema: AccountSchema,
},
},
},
400: {
description: "User is local",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const otherUser = context.get("user");
if (otherUser.isLocal()) {
throw new ApiError(400, "Cannot refetch a local user");
}
const newUser = await otherUser.updateFromRemote();
return context.json(newUser.toApi(false), 200);
}),
);

View file

@ -1,75 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/remove_from_followers",
summary: "Remove account from followers",
description: "Remove the given account from your followers.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#remove_from_followers",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:follows"],
permissions: [
RolePermission.ManageOwnFollows,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description:
"Successfully removed from followers, or account was already not following you",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
otherUser,
user,
);
if (oppositeRelationship.data.following) {
await oppositeRelationship.update({
following: false,
});
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -1,126 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Role as RoleSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Role } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const routePost = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/roles/{role_id}",
summary: "Assign role to account",
tags: ["Accounts"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.ManageRoles],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
role_id: RoleSchema.shape.id,
}),
},
responses: {
204: {
description: "Role assigned",
},
404: ApiError.roleNotFound().schema,
403: ApiError.forbidden().schema,
},
});
const routeDelete = createRoute({
method: "delete",
path: "/api/v1/accounts/{id}/roles/{role_id}",
summary: "Remove role from user",
tags: ["Accounts"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.ManageRoles],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
role_id: RoleSchema.shape.id,
}),
},
responses: {
204: {
description: "Role removed",
},
404: ApiError.roleNotFound().schema,
403: ApiError.forbidden().schema,
},
});
export default apiRoute((app) => {
app.openapi(routePost, async (context) => {
const { user } = context.get("auth");
const { role_id } = context.req.valid("param");
const targetUser = context.get("user");
const role = await Role.fromId(role_id);
if (!role) {
throw ApiError.roleNotFound();
}
// Priority check
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const userHighestRole = userRoles.reduce((prev, current) =>
prev.data.priority > current.data.priority ? prev : current,
);
if (role.data.priority > userHighestRole.data.priority) {
throw new ApiError(
403,
"Forbidden",
`User with highest role priority ${userHighestRole.data.priority} cannot assign role with priority ${role.data.priority}`,
);
}
await role.linkUser(targetUser.id);
return context.body(null, 204);
});
app.openapi(routeDelete, async (context) => {
const { user } = context.get("auth");
const { role_id } = context.req.valid("param");
const targetUser = context.get("user");
const role = await Role.fromId(role_id);
if (!role) {
throw ApiError.roleNotFound();
}
// Priority check
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
const userHighestRole = userRoles.reduce((prev, current) =>
prev.data.priority > current.data.priority ? prev : current,
);
if (role.data.priority > userHighestRole.data.priority) {
throw new ApiError(
403,
"Forbidden",
`User with highest role priority ${userHighestRole.data.priority} cannot remove role with priority ${role.data.priority}`,
);
}
await role.unlinkUser(targetUser.id);
return context.body(null, 204);
});
});

View file

@ -1,51 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Role as RoleSchema,
} from "@versia/client/schemas";
import { Role } from "@versia/kit/db";
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}/roles",
summary: "List account roles",
tags: ["Accounts"],
middleware: [
auth({
auth: false,
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description: "List of roles",
content: {
"application/json": {
schema: z.array(RoleSchema),
},
},
},
},
});
export default apiRoute((app) => {
app.openapi(route, async (context) => {
const targetUser = context.get("user");
const roles = await Role.getUserRoles(
targetUser.id,
targetUser.data.isAdmin,
);
return context.json(
roles.map((role) => role.toApi()),
200,
);
});
});

View file

@ -1,140 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Status as StatusSchema,
zBoolean,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}/statuses",
summary: "Get accounts statuses",
description: "Statuses posted to the given account.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#statuses",
},
tags: ["Accounts"],
middleware: [
auth({
auth: false,
permissions: [
RolePermission.ViewNotes,
RolePermission.ViewAccounts,
],
scopes: ["read:statuses"],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
query: z.object({
max_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: StatusSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.coerce.number().int().min(1).max(40).default(20).openapi({
description: "Maximum number of results to return.",
}),
only_media: zBoolean.default(false).openapi({
description: "Filter out statuses without attachments.",
}),
exclude_replies: zBoolean.default(false).openapi({
description:
"Filter out statuses in reply to a different account.",
}),
exclude_reblogs: zBoolean.default(false).openapi({
description: "Filter out boosts from the response.",
}),
pinned: zBoolean.default(false).openapi({
description:
"Filter for pinned statuses only. Pinned statuses do not receive special priority in the order of the returned results.",
}),
tagged: z.string().optional().openapi({
description: "Filter for statuses using a specific hashtag.",
}),
}),
},
responses: {
200: {
description: "Statuses posted to the given account.",
content: {
"application/json": {
schema: z.array(StatusSchema),
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const {
max_id,
min_id,
since_id,
limit,
exclude_reblogs,
only_media,
exclude_replies,
pinned,
} = context.req.valid("query");
const { objects } = await Timeline.getNoteTimeline(
and(
max_id ? lt(Notes.id, max_id) : undefined,
since_id ? gte(Notes.id, since_id) : undefined,
min_id ? gt(Notes.id, min_id) : undefined,
eq(Notes.authorId, otherUser.id),
only_media
? sql`EXISTS (SELECT 1 FROM "Medias" WHERE "Medias"."noteId" = ${Notes.id})`
: undefined,
pinned
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
: undefined,
// Visibility check
or(
sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${otherUser.id})`,
and(
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
inArray(Notes.visibility, ["public", "private"]),
),
inArray(Notes.visibility, ["public", "unlisted"]),
),
exclude_reblogs ? isNull(Notes.reblogId) : undefined,
exclude_replies ? isNull(Notes.replyId) : undefined,
),
limit,
new URL(context.req.url),
user?.id,
);
return context.json(
await Promise.all(objects.map((note) => note.toApi(otherUser))),
200,
);
}),
);

View file

@ -1,70 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/unblock",
summary: "Unblock account",
description: "Unblock the given account.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#unblock",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:blocks"],
permissions: [
RolePermission.ManageOwnBlocks,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description:
"Successfully unblocked, or account was already not blocked",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (foundRelationship.data.blocking) {
await foundRelationship.update({
blocking: false,
});
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -1,66 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/unfollow",
summary: "Unfollow account",
description: "Unfollow the given account.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#unfollow",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:follows"],
permissions: [
RolePermission.ManageOwnFollows,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description:
"Successfully unfollowed, or account was already not followed",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
await user.unfollow(otherUser, foundRelationship);
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -1,70 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/unmute",
summary: "Unmute account",
description: "Unmute the given account.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#unmute",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:mutes"],
permissions: [
RolePermission.ManageOwnMutes,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description: "Successfully unmuted, or account was already unmuted",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (foundRelationship.data.muting) {
await foundRelationship.update({
muting: false,
mutingNotifications: false,
});
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -1,70 +0,0 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/unpin",
summary: "Unfeature account from profile",
description: "Remove the given account from the users featured profiles.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#unpin",
},
tags: ["Accounts"],
middleware: [
auth({
auth: true,
scopes: ["write:accounts"],
permissions: [
RolePermission.ManageOwnAccount,
RolePermission.ViewAccounts,
],
}),
withUserParam,
] as const,
request: {
params: z.object({
id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description:
"Successfully unendorsed, or account was already not endorsed",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (foundRelationship.data.endorsed) {
await foundRelationship.update({
endorsed: false,
});
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,62 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/block",
describeRoute({
summary: "Block account",
description:
"Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#block",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Successfully blocked, or account was already blocked.",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:blocks"],
permissions: [
RolePermission.ManageOwnBlocks,
RolePermission.ViewAccounts,
],
}),
async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (!foundRelationship.data.blocking) {
await foundRelationship.update({
blocking: true,
});
}
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,102 @@
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import {
Relationship as RelationshipSchema,
iso631,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/follow",
describeRoute({
summary: "Follow account",
description:
"Follow the given account. Can also be used to update whether to show reblogs or enable notifications.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#follow",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Successfully followed, or account was already followed",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
403: {
description:
"Trying to follow someone that you block or that blocks you",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:follows"],
permissions: [
RolePermission.ManageOwnFollows,
RolePermission.ViewAccounts,
],
}),
validator(
"json",
z.object({
reblogs: z.boolean().default(true).openapi({
description:
"Receive this accounts reblogs in home timeline?",
example: true,
}),
notify: z.boolean().default(false).openapi({
description:
"Receive notifications when this account posts a status?",
example: false,
}),
languages: z
.array(iso631)
.default([])
.openapi({
description:
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this accounts posts in all languages.",
example: ["en", "fr"],
}),
}),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const { reblogs, notify, languages } = context.req.valid("json");
const otherUser = context.get("user");
let relationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (!relationship.data.following) {
relationship = await user.followRequest(otherUser, {
reblogs,
notify,
languages,
});
}
return context.json(relationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,111 @@
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/followers",
describeRoute({
summary: "Get accounts followers",
description:
"Accounts which follow the given account, if network is not hidden by the account owner.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#followers",
},
tags: ["Accounts"],
responses: {
200: {
description: "Accounts which follow the given account.",
content: {
"application/json": {
schema: resolver(z.array(AccountSchema)),
},
},
headers: resolver(
z.object({
link: z
.string()
.optional()
.openapi({
description:
"Links to the next and previous pages",
example:
'<https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/followers?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/followers?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
),
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: false,
scopes: ["read:accounts"],
permissions: [
RolePermission.ViewAccountFollows,
RolePermission.ViewAccounts,
],
}),
validator(
"query",
z.object({
max_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: AccountSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.number().int().min(1).max(40).default(20).openapi({
description: "Maximum number of results to return.",
}),
}),
handleZodError,
),
async (context) => {
const { max_id, since_id, min_id, limit } =
context.req.valid("query");
const otherUser = context.get("user");
// TODO: Add follower/following privacy settings
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
),
limit,
new URL(context.req.url),
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
},
);
},
),
);

View file

@ -0,0 +1,112 @@
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/following",
describeRoute({
summary: "Get accounts following",
description:
"Accounts which the given account is following, if network is not hidden by the account owner.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#following",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Accounts which the given account is following.",
content: {
"application/json": {
schema: resolver(z.array(AccountSchema)),
},
},
headers: resolver(
z.object({
link: z
.string()
.optional()
.openapi({
description:
"Links to the next and previous pages",
example:
'<https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/following?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/following?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
),
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: false,
scopes: ["read:accounts"],
permissions: [
RolePermission.ViewAccountFollows,
RolePermission.ViewAccounts,
],
}),
validator(
"query",
z.object({
max_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: AccountSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.number().int().min(1).max(40).default(20).openapi({
description: "Maximum number of results to return.",
}),
}),
handleZodError,
),
async (context) => {
const { max_id, since_id, min_id } = context.req.valid("query");
const otherUser = context.get("user");
// TODO: Add follower/following privacy settings
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
),
context.req.valid("query").limit,
new URL(context.req.url),
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
},
);
},
),
);

View file

@ -0,0 +1,47 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id",
describeRoute({
summary: "Get account",
description: "View information about a profile.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#get",
},
tags: ["Accounts"],
responses: {
200: {
description:
"The Account record will be returned. Note that acct of local users does not include the domain name.",
content: {
"application/json": {
schema: resolver(AccountSchema),
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: false,
permissions: [RolePermission.ViewAccounts],
}),
(context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
return context.json(
otherUser.toApi(user?.id === otherUser.id),
200,
);
},
),
);

View file

@ -0,0 +1,84 @@
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/mute",
describeRoute({
summary: "Mute account",
description:
"Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#mute",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Successfully muted, or account was already muted. Note that you can call this API method again with notifications=false to update the relationship so that only statuses are muted.",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:mutes"],
permissions: [
RolePermission.ManageOwnMutes,
RolePermission.ViewAccounts,
],
}),
validator(
"json",
z.object({
notifications: z.boolean().default(true).openapi({
description: "Mute notifications in addition to statuses?",
}),
duration: z
.number()
.int()
.min(0)
.max(60 * 60 * 24 * 365 * 5)
.default(0)
.openapi({
description:
"How long the mute should last, in seconds.",
}),
}),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
// TODO: Add duration support
const { notifications } = context.req.valid("json");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
// TODO: Implement duration
await foundRelationship.update({
muting: true,
mutingNotifications: notifications,
});
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,70 @@
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/note",
describeRoute({
summary: "Set private note on profile",
description: "Sets a private note on a user.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#note",
},
tags: ["Accounts"],
responses: {
200: {
description: "Successfully updated profile note",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:accounts"],
permissions: [
RolePermission.ManageOwnAccount,
RolePermission.ViewAccounts,
],
}),
validator(
"json",
z.object({
comment: RelationshipSchema.shape.note.optional().openapi({
description:
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
}),
}),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const { comment } = context.req.valid("json");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
await foundRelationship.update({
note: comment ?? "",
});
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,54 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/pin",
describeRoute({
summary: "Feature account on your profile",
description:
"Add the given account to the users featured profiles. (Featured profiles are currently shown on the users own public profile.)",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#pin",
},
tags: ["Accounts"],
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:accounts"],
permissions: [
RolePermission.ManageOwnAccount,
RolePermission.ViewAccounts,
],
}),
async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
await foundRelationship.update({
endorsed: true,
});
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,56 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/refetch",
describeRoute({
summary: "Refetch account",
description:
"Refetch the given account's profile from the remote server",
tags: ["Accounts"],
responses: {
200: {
description: "Refetched account data",
content: {
"application/json": {
schema: resolver(AccountSchema),
},
},
},
400: {
description: "User is local",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:accounts"],
permissions: [RolePermission.ViewAccounts],
}),
async (context) => {
const otherUser = context.get("user");
if (otherUser.isLocal()) {
throw new ApiError(400, "Cannot refetch a local user");
}
const newUser = await otherUser.updateFromRemote();
return context.json(newUser.toApi(false), 200);
},
),
);

View file

@ -0,0 +1,66 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/remove_from_followers",
describeRoute({
summary: "Remove account from followers",
description: "Remove the given account from your followers.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#remove_from_followers",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Successfully removed from followers, or account was already not following you",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:follows"],
permissions: [
RolePermission.ManageOwnFollows,
RolePermission.ViewAccounts,
],
}),
async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
otherUser,
user,
);
if (oppositeRelationship.data.following) {
await oppositeRelationship.update({
following: false,
});
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,127 @@
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import {
Account as AccountSchema,
Role as RoleSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Role } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => {
app.post(
"/api/v1/accounts/:id/roles/:role_id",
describeRoute({
summary: "Assign role to account",
tags: ["Accounts"],
responses: {
204: {
description: "Role assigned",
},
404: ApiError.roleNotFound().schema,
403: ApiError.forbidden().schema,
},
}),
withUserParam,
validator(
"param",
z.object({
id: AccountSchema.shape.id,
role_id: RoleSchema.shape.id,
}),
handleZodError,
),
auth({
auth: true,
permissions: [RolePermission.ManageRoles],
}),
async (context) => {
const { user } = context.get("auth");
const { role_id } = context.req.valid("param");
const targetUser = context.get("user");
const role = await Role.fromId(role_id);
if (!role) {
throw ApiError.roleNotFound();
}
// Priority check
const userRoles = await Role.getUserRoles(
user.id,
user.data.isAdmin,
);
const userHighestRole = userRoles.reduce((prev, current) =>
prev.data.priority > current.data.priority ? prev : current,
);
if (role.data.priority > userHighestRole.data.priority) {
throw new ApiError(
403,
"Forbidden",
`User with highest role priority ${userHighestRole.data.priority} cannot assign role with priority ${role.data.priority}`,
);
}
await role.linkUser(targetUser.id);
return context.body(null, 204);
},
);
app.delete(
"/api/v1/accounts/:id/roles/:role_id",
describeRoute({
summary: "Remove role from user",
tags: ["Accounts"],
}),
withUserParam,
validator(
"param",
z.object({
id: AccountSchema.shape.id,
role_id: RoleSchema.shape.id,
}),
handleZodError,
),
auth({
auth: true,
permissions: [RolePermission.ManageRoles],
}),
async (context) => {
const { user } = context.get("auth");
const { role_id } = context.req.valid("param");
const targetUser = context.get("user");
const role = await Role.fromId(role_id);
if (!role) {
throw ApiError.roleNotFound();
}
// Priority check
const userRoles = await Role.getUserRoles(
user.id,
user.data.isAdmin,
);
const userHighestRole = userRoles.reduce((prev, current) =>
prev.data.priority > current.data.priority ? prev : current,
);
if (role.data.priority > userHighestRole.data.priority) {
throw new ApiError(
403,
"Forbidden",
`User with highest role priority ${userHighestRole.data.priority} cannot remove role with priority ${role.data.priority}`,
);
}
await role.unlinkUser(targetUser.id);
return context.body(null, 204);
},
);
});

View file

@ -0,0 +1,43 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Role as RoleSchema } from "@versia/client/schemas";
import { Role } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { z } from "zod";
export default apiRoute((app) => {
app.get(
"/api/v1/accounts/:id/roles",
describeRoute({
summary: "List account roles",
tags: ["Accounts"],
responses: {
200: {
description: "List of roles",
content: {
"application/json": {
schema: resolver(z.array(RoleSchema)),
},
},
},
},
}),
withUserParam,
auth({
auth: false,
}),
async (context) => {
const targetUser = context.get("user");
const roles = await Role.getUserRoles(
targetUser.id,
targetUser.data.isAdmin,
);
return context.json(
roles.map((role) => role.toApi()),
200,
);
},
);
});

View file

@ -95,7 +95,7 @@ describe("/api/v1/accounts/:id/statuses", () => {
expect(ok2).toBe(true); expect(ok2).toBe(true);
const { data: data2, ok: ok3 } = await client0.getAccountStatuses( const { data: data3, ok: ok3 } = await client0.getAccountStatuses(
users[1].id, users[1].id,
{ {
pinned: true, pinned: true,
@ -103,7 +103,7 @@ describe("/api/v1/accounts/:id/statuses", () => {
); );
expect(ok3).toBe(true); expect(ok3).toBe(true);
expect(data2).toBeArrayOfSize(1); expect(data3).toBeArrayOfSize(1);
expect(data2[0].id).toBe(timeline[3].id); expect(data3[0].id).toBe(timeline[3].id);
}); });
}); });

View file

@ -0,0 +1,142 @@
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { Status as StatusSchema, zBoolean } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/statuses",
describeRoute({
summary: "Get accounts statuses",
description: "Statuses posted to the given account.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#statuses",
},
tags: ["Accounts"],
responses: {
200: {
description: "Statuses posted to the given account.",
content: {
"application/json": {
schema: resolver(z.array(StatusSchema)),
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: false,
permissions: [
RolePermission.ViewNotes,
RolePermission.ViewAccounts,
],
scopes: ["read:statuses"],
}),
validator(
"query",
z.object({
max_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: StatusSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.coerce
.number()
.int()
.min(1)
.max(40)
.default(20)
.openapi({
description: "Maximum number of results to return.",
}),
only_media: zBoolean.default(false).openapi({
description: "Filter out statuses without attachments.",
}),
exclude_replies: zBoolean.default(false).openapi({
description:
"Filter out statuses in reply to a different account.",
}),
exclude_reblogs: zBoolean.default(false).openapi({
description: "Filter out boosts from the response.",
}),
pinned: zBoolean.default(false).openapi({
description:
"Filter for pinned statuses only. Pinned statuses do not receive special priority in the order of the returned results.",
}),
tagged: z.string().optional().openapi({
description:
"Filter for statuses using a specific hashtag.",
}),
}),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const {
max_id,
min_id,
since_id,
limit,
exclude_reblogs,
only_media,
exclude_replies,
pinned,
} = context.req.valid("query");
const { objects } = await Timeline.getNoteTimeline(
and(
max_id ? lt(Notes.id, max_id) : undefined,
since_id ? gte(Notes.id, since_id) : undefined,
min_id ? gt(Notes.id, min_id) : undefined,
eq(Notes.authorId, otherUser.id),
only_media
? sql`EXISTS (SELECT 1 FROM "Medias" WHERE "Medias"."noteId" = ${Notes.id})`
: undefined,
pinned
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
: undefined,
// Visibility check
or(
sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${otherUser.id})`,
and(
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
inArray(Notes.visibility, ["public", "private"]),
),
inArray(Notes.visibility, ["public", "unlisted"]),
),
exclude_reblogs ? isNull(Notes.reblogId) : undefined,
exclude_replies ? isNull(Notes.replyId) : undefined,
),
limit,
new URL(context.req.url),
user?.id,
);
return context.json(
await Promise.all(objects.map((note) => note.toApi(otherUser))),
200,
);
},
),
);

View file

@ -0,0 +1,61 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/unblock",
describeRoute({
summary: "Unblock account",
description: "Unblock the given account.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#unblock",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Successfully unblocked, or account was already not blocked",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:blocks"],
permissions: [
RolePermission.ManageOwnBlocks,
RolePermission.ViewAccounts,
],
}),
async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (foundRelationship.data.blocking) {
await foundRelationship.update({
blocking: false,
});
}
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,57 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/unfollow",
describeRoute({
summary: "Unfollow account",
description: "Unfollow the given account.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#unfollow",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Successfully unfollowed, or account was already not followed",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:follows"],
permissions: [
RolePermission.ManageOwnFollows,
RolePermission.ViewAccounts,
],
}),
async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
await user.unfollow(otherUser, foundRelationship);
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,62 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/unmute",
describeRoute({
summary: "Unmute account",
description: "Unmute the given account.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#unmute",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Successfully unmuted, or account was already unmuted",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:mutes"],
permissions: [
RolePermission.ManageOwnMutes,
RolePermission.ViewAccounts,
],
}),
async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (foundRelationship.data.muting) {
await foundRelationship.update({
muting: false,
mutingNotifications: false,
});
}
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,62 @@
import { apiRoute, auth, withUserParam } from "@/api";
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/unpin",
describeRoute({
summary: "Unfeature account from profile",
description:
"Remove the given account from the users featured profiles.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#unpin",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Successfully unendorsed, or account was already not endorsed",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: true,
scopes: ["write:accounts"],
permissions: [
RolePermission.ManageOwnAccount,
RolePermission.ViewAccounts,
],
}),
async (context) => {
const { user } = context.get("auth");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (foundRelationship.data.endorsed) {
await foundRelationship.update({
endorsed: false,
});
}
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -1,5 +1,4 @@
import { apiRoute, auth, qsQuery } from "@/api"; import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { import {
Account as AccountSchema, Account as AccountSchema,
FamiliarFollowers as FamiliarFollowersSchema, FamiliarFollowers as FamiliarFollowersSchema,
@ -8,71 +7,75 @@ import { RolePermission } from "@versia/client/schemas";
import { User, db } from "@versia/kit/db"; import { User, db } from "@versia/kit/db";
import type { Users } from "@versia/kit/tables"; import type { Users } from "@versia/kit/tables";
import { type InferSelectModel, sql } from "drizzle-orm"; import { type InferSelectModel, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit"; import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/accounts/familiar_followers", "/api/v1/accounts/familiar_followers",
summary: "Get familiar followers", describeRoute({
description: summary: "Get familiar followers",
"Obtain a list of all accounts that follow a given account, filtered for accounts you follow.", description:
externalDocs: { "Obtain a list of all accounts that follow a given account, filtered for accounts you follow.",
url: "https://docs.joinmastodon.org/methods/accounts/#familiar_followers", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/accounts/#familiar_followers",
tags: ["Accounts"], },
middleware: [ tags: ["Accounts"],
responses: {
200: {
description: "Familiar followers",
content: {
"application/json": {
schema: resolver(z.array(FamiliarFollowersSchema)),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
qsQuery(),
auth({ auth({
auth: true, auth: true,
scopes: ["read:follows"], scopes: ["read:follows"],
permissions: [RolePermission.ManageOwnFollows], permissions: [RolePermission.ManageOwnFollows],
}), }),
rateLimit(5), rateLimit(5),
qsQuery(), validator(
] as const, "query",
request: { z.object({
query: z.object({ id: z
id: z .array(AccountSchema.shape.id)
.array(AccountSchema.shape.id) .min(1)
.min(1) .max(10)
.max(10) .or(AccountSchema.shape.id.transform((v) => [v]))
.or(AccountSchema.shape.id.transform((v) => [v])) .openapi({
.openapi({ description:
description: "Find familiar followers for the provided account IDs.",
"Find familiar followers for the provided account IDs.", example: [
example: [ "f137ce6f-ff5e-4998-b20f-0361ba9be007",
"f137ce6f-ff5e-4998-b20f-0361ba9be007", "8424c654-5d03-4a1b-bec8-4e87db811b5d",
"8424c654-5d03-4a1b-bec8-4e87db811b5d", ],
], }),
}), }),
}), handleZodError,
}, ),
responses: { async (context) => {
200: { const { user } = context.get("auth");
description: "Familiar followers", const { id: ids } = context.req.valid("query");
content: {
"application/json": {
schema: z.array(FamiliarFollowersSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) => // Find followers of the accounts in "ids", that you also follow
app.openapi(route, async (context) => { const finalUsers = await Promise.all(
const { user } = context.get("auth"); ids.map(async (id) => ({
const { id: ids } = context.req.valid("query"); id,
accounts: await User.fromIds(
// Find followers of the accounts in "ids", that you also follow (
const finalUsers = await Promise.all( await db.execute(sql<
ids.map(async (id) => ({ InferSelectModel<typeof Users>
id, >`
accounts: await User.fromIds(
(
await db.execute(sql<InferSelectModel<typeof Users>>`
SELECT "Users"."id" FROM "Users" SELECT "Users"."id" FROM "Users"
INNER JOIN "Relationships" AS "SelfFollowing" INNER JOIN "Relationships" AS "SelfFollowing"
ON "SelfFollowing"."subjectId" = "Users"."id" ON "SelfFollowing"."subjectId" = "Users"."id"
@ -85,17 +88,18 @@ export default apiRoute((app) =>
AND "IdsFollowers"."following" = true AND "IdsFollowers"."following" = true
) )
`) `)
).rows.map((u) => u.id as string), ).rows.map((u) => u.id as string),
), ),
})), })),
); );
return context.json( return context.json(
finalUsers.map((u) => ({ finalUsers.map((u) => ({
...u, ...u,
accounts: u.accounts.map((a) => a.toApi()), accounts: u.accounts.map((a) => a.toApi()),
})), })),
200, 200,
); );
}), },
),
); );

View file

@ -1,11 +1,13 @@
import { apiRoute, auth, jsonOrForm } from "@/api"; import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { tempmailDomains } from "@/tempmail"; import { tempmailDomains } from "@/tempmail";
import { createRoute, z } from "@hono/zod-openapi";
import { zBoolean } from "@versia/client/schemas"; import { zBoolean } from "@versia/client/schemas";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit"; import { rateLimit } from "~/middlewares/rate-limit";
@ -40,17 +42,100 @@ const schema = z.object({
}), }),
}); });
const route = createRoute({ export default apiRoute((app) =>
method: "post", app.post(
path: "/api/v1/accounts", "/api/v1/accounts",
summary: "Register an account", describeRoute({
description: summary: "Register an account",
"Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.\n\nA relationship between the OAuth Application and created user account is stored.", description:
externalDocs: { "Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.\n\nA relationship between the OAuth Application and created user account is stored.",
url: "https://docs.joinmastodon.org/methods/accounts/#create", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/accounts/#create",
tags: ["Accounts"], },
middleware: [ tags: ["Accounts"],
responses: {
200: {
description: "Token for the created account",
},
401: ApiError.missingAuthentication().schema,
422: {
description: "Validation failed",
content: {
"application/json": {
schema: resolver(
z.object({
error: z.string(),
details: z.object({
username: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
"ERR_TOO_LONG",
"ERR_TOO_SHORT",
"ERR_BLOCKED",
"ERR_TAKEN",
"ERR_RESERVED",
"ERR_ACCEPTED",
"ERR_INCLUSION",
]),
description: z.string(),
}),
),
email: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
"ERR_BLOCKED",
"ERR_TAKEN",
]),
description: z.string(),
}),
),
password: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
"ERR_TOO_LONG",
"ERR_TOO_SHORT",
]),
description: z.string(),
}),
),
agreement: z.array(
z.object({
error: z.enum(["ERR_ACCEPTED"]),
description: z.string(),
}),
),
locale: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
]),
description: z.string(),
}),
),
reason: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_TOO_LONG",
]),
description: z.string(),
}),
),
}),
}),
),
},
},
},
},
}),
auth({ auth({
auth: false, auth: false,
scopes: ["write:accounts"], scopes: ["write:accounts"],
@ -58,315 +143,223 @@ const route = createRoute({
}), }),
rateLimit(5), rateLimit(5),
jsonOrForm(), jsonOrForm(),
] as const, validator("json", schema, handleZodError),
request: { async (context) => {
body: { const form = context.req.valid("json");
content: { const { username, email, password, agreement, locale } =
"application/json": { context.req.valid("json");
schema,
},
"multipart/form-data": {
schema,
},
"application/x-www-form-urlencoded": {
schema,
},
},
},
},
responses: {
200: {
description: "Token for the created account",
},
401: ApiError.missingAuthentication().schema,
422: {
description: "Validation failed",
content: {
"application/json": {
schema: z.object({
error: z.string(),
details: z.object({
username: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
"ERR_TOO_LONG",
"ERR_TOO_SHORT",
"ERR_BLOCKED",
"ERR_TAKEN",
"ERR_RESERVED",
"ERR_ACCEPTED",
"ERR_INCLUSION",
]),
description: z.string(),
}),
),
email: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
"ERR_BLOCKED",
"ERR_TAKEN",
]),
description: z.string(),
}),
),
password: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
"ERR_TOO_LONG",
"ERR_TOO_SHORT",
]),
description: z.string(),
}),
),
agreement: z.array(
z.object({
error: z.enum(["ERR_ACCEPTED"]),
description: z.string(),
}),
),
locale: z.array(
z.object({
error: z.enum(["ERR_BLANK", "ERR_INVALID"]),
description: z.string(),
}),
),
reason: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_TOO_LONG",
]),
description: z.string(),
}),
),
}),
}),
},
},
},
},
});
export default apiRoute((app) => if (!config.registration.allow) {
app.openapi(route, async (context) => { throw new ApiError(422, "Registration is disabled");
const form = context.req.valid("json"); }
const { username, email, password, agreement, locale } =
context.req.valid("json");
if (!config.registration.allow) { const errors: {
throw new ApiError(422, "Registration is disabled"); details: Record<
} string,
{
error:
| "ERR_BLANK"
| "ERR_INVALID"
| "ERR_TOO_LONG"
| "ERR_TOO_SHORT"
| "ERR_BLOCKED"
| "ERR_TAKEN"
| "ERR_RESERVED"
| "ERR_ACCEPTED"
| "ERR_INCLUSION";
description: string;
}[]
>;
} = {
details: {
password: [],
username: [],
email: [],
agreement: [],
locale: [],
reason: [],
},
};
const errors: { // Check if fields are blank
details: Record< for (const value of [
string, "username",
{ "email",
error: "password",
| "ERR_BLANK" "agreement",
| "ERR_INVALID" "locale",
| "ERR_TOO_LONG" "reason",
| "ERR_TOO_SHORT" ]) {
| "ERR_BLOCKED" // @ts-expect-error We don't care about the type here
| "ERR_TAKEN" if (!form[value]) {
| "ERR_RESERVED" errors.details[value].push({
| "ERR_ACCEPTED" error: "ERR_BLANK",
| "ERR_INCLUSION"; description: "can't be blank",
description: string; });
}[] }
>; }
} = {
details: {
password: [],
username: [],
email: [],
agreement: [],
locale: [],
reason: [],
},
};
// Check if fields are blank // Check if username is valid
for (const value of [ if (!username?.match(/^[a-z0-9_]+$/)) {
"username", errors.details.username.push({
"email", error: "ERR_INVALID",
"password", description:
"agreement", "must only contain lowercase letters, numbers, and underscores",
"locale", });
"reason", }
]) {
// @ts-expect-error We don't care about the type here // Check if username doesnt match filters
if (!form[value]) { if (
errors.details[value].push({ config.validation.filters.username.some((filter) =>
filter.test(username),
)
) {
errors.details.username.push({
error: "ERR_INVALID",
description: "contains blocked words",
});
}
// Check if username is too long
if (
(username?.length ?? 0) >
config.validation.accounts.max_username_characters
) {
errors.details.username.push({
error: "ERR_TOO_LONG",
description: `is too long (maximum is ${config.validation.accounts.max_username_characters} characters)`,
});
}
// Check if username is too short
if ((username?.length ?? 0) < 3) {
errors.details.username.push({
error: "ERR_TOO_SHORT",
description: "is too short (minimum is 3 characters)",
});
}
// Check if username is reserved
if (
config.validation.accounts.disallowed_usernames.some((filter) =>
filter.test(username),
)
) {
errors.details.username.push({
error: "ERR_RESERVED",
description: "is reserved",
});
}
// Check if username is taken
if (
await User.fromSql(
and(eq(Users.username, username), isNull(Users.instanceId)),
)
) {
errors.details.username.push({
error: "ERR_TAKEN",
description: "is already taken",
});
}
// Check if email is valid
if (
!email?.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
)
) {
errors.details.email.push({
error: "ERR_INVALID",
description: "must be a valid email address",
});
}
// Check if email is blocked
if (
config.validation.emails.disallowed_domains.some((f) =>
f.test(email.split("@")[1]),
) ||
(config.validation.emails.disallow_tempmail &&
tempmailDomains.domains.includes(email.split("@")[1]))
) {
errors.details.email.push({
error: "ERR_BLOCKED",
description: "is from a blocked email provider",
});
}
// Check if email is taken
if (await User.fromSql(eq(Users.email, email))) {
errors.details.email.push({
error: "ERR_TAKEN",
description: "is already taken",
});
}
// Check if agreement is accepted
if (!agreement) {
errors.details.agreement.push({
error: "ERR_ACCEPTED",
description: "must be accepted",
});
}
if (!locale) {
errors.details.locale.push({
error: "ERR_BLANK", error: "ERR_BLANK",
description: "can't be blank", description: "can't be blank",
}); });
} }
}
// Check if username is valid if (!ISO6391.validate(locale ?? "")) {
if (!username?.match(/^[a-z0-9_]+$/)) { errors.details.locale.push({
errors.details.username.push({ error: "ERR_INVALID",
error: "ERR_INVALID", description: "must be a valid ISO 639-1 code",
description: });
"must only contain lowercase letters, numbers, and underscores", }
});
}
// Check if username doesnt match filters // Check if reason is too long
if ( if ((form.reason?.length ?? 0) > 10_000) {
config.validation.filters.username.some((filter) => errors.details.reason.push({
filter.test(username), error: "ERR_TOO_LONG",
) description: `is too long (maximum is ${10_000} characters)`,
) { });
errors.details.username.push({ }
error: "ERR_INVALID",
description: "contains blocked words",
});
}
// Check if username is too long // If any errors are present, return them
if ( if (
(username?.length ?? 0) > Object.values(errors.details).some((value) => value.length > 0)
config.validation.accounts.max_username_characters ) {
) { // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
errors.details.username.push({
error: "ERR_TOO_LONG",
description: `is too long (maximum is ${config.validation.accounts.max_username_characters} characters)`,
});
}
// Check if username is too short const errorsText = Object.entries(errors.details)
if ((username?.length ?? 0) < 3) { .filter(([_, errors]) => errors.length > 0)
errors.details.username.push({ .map(
error: "ERR_TOO_SHORT", ([name, errors]) =>
description: "is too short (minimum is 3 characters)", `${name} ${errors
}); .map((error) => error.description)
} .join(", ")}`,
)
// Check if username is reserved .join(", ");
if ( throw new ApiError(
config.validation.accounts.disallowed_usernames.some((filter) => 422,
filter.test(username), `Validation failed: ${errorsText}`,
) Object.fromEntries(
) { Object.entries(errors.details).filter(
errors.details.username.push({ ([_, errors]) => errors.length > 0,
error: "ERR_RESERVED", ),
description: "is reserved",
});
}
// Check if username is taken
if (
await User.fromSql(
and(eq(Users.username, username), isNull(Users.instanceId)),
)
) {
errors.details.username.push({
error: "ERR_TAKEN",
description: "is already taken",
});
}
// Check if email is valid
if (
!email?.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
)
) {
errors.details.email.push({
error: "ERR_INVALID",
description: "must be a valid email address",
});
}
// Check if email is blocked
if (
config.validation.emails.disallowed_domains.some((f) =>
f.test(email.split("@")[1]),
) ||
(config.validation.emails.disallow_tempmail &&
tempmailDomains.domains.includes(email.split("@")[1]))
) {
errors.details.email.push({
error: "ERR_BLOCKED",
description: "is from a blocked email provider",
});
}
// Check if email is taken
if (await User.fromSql(eq(Users.email, email))) {
errors.details.email.push({
error: "ERR_TAKEN",
description: "is already taken",
});
}
// Check if agreement is accepted
if (!agreement) {
errors.details.agreement.push({
error: "ERR_ACCEPTED",
description: "must be accepted",
});
}
if (!locale) {
errors.details.locale.push({
error: "ERR_BLANK",
description: "can't be blank",
});
}
if (!ISO6391.validate(locale ?? "")) {
errors.details.locale.push({
error: "ERR_INVALID",
description: "must be a valid ISO 639-1 code",
});
}
// Check if reason is too long
if ((form.reason?.length ?? 0) > 10_000) {
errors.details.reason.push({
error: "ERR_TOO_LONG",
description: `is too long (maximum is ${10_000} characters)`,
});
}
// If any errors are present, return them
if (Object.values(errors.details).some((value) => value.length > 0)) {
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
const errorsText = Object.entries(errors.details)
.filter(([_, errors]) => errors.length > 0)
.map(
([name, errors]) =>
`${name} ${errors
.map((error) => error.description)
.join(", ")}`,
)
.join(", ");
throw new ApiError(
422,
`Validation failed: ${errorsText}`,
Object.fromEntries(
Object.entries(errors.details).filter(
([_, errors]) => errors.length > 0,
), ),
), );
); }
}
await User.fromDataLocal({ await User.fromDataLocal({
username, username,
password, password,
email, email,
}); });
return context.text("", 200); return context.text("", 200);
}), },
),
); );

View file

@ -1,108 +1,113 @@
import { apiRoute, auth, parseUserAddress } from "@/api"; import { apiRoute, auth, handleZodError, parseUserAddress } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas"; import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Instance, User } from "@versia/kit/db"; import { Instance, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit"; import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/accounts/lookup", "/api/v1/accounts/lookup",
summary: "Lookup account ID from Webfinger address", describeRoute({
description: summary: "Lookup account ID from Webfinger address",
"Quickly lookup a username to see if it is available, skipping WebFinger resolution.", description:
tags: ["Accounts"], "Quickly lookup a username to see if it is available, skipping WebFinger resolution.",
middleware: [ tags: ["Accounts"],
responses: {
200: {
description: "Account",
content: {
"application/json": {
schema: resolver(AccountSchema),
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: false, auth: false,
permissions: [RolePermission.Search], permissions: [RolePermission.Search],
}), }),
rateLimit(5), rateLimit(5),
] as const, validator(
request: { "query",
query: z.object({ z.object({
acct: AccountSchema.shape.acct.openapi({ acct: AccountSchema.shape.acct.openapi({
description: "The username or Webfinger address to lookup.", description: "The username or Webfinger address to lookup.",
example: "lexi@beta.versia.social", example: "lexi@beta.versia.social",
}),
}), }),
}), handleZodError,
}, ),
responses: { async (context) => {
200: { const { acct } = context.req.valid("query");
description: "Account", const { user } = context.get("auth");
content: {
"application/json": {
schema: AccountSchema,
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) => // Check if acct is matching format username@domain.com or @username@domain.com
app.openapi(route, async (context) => { const { username, domain } = parseUserAddress(acct);
const { acct } = context.req.valid("query");
const { user } = context.get("auth");
// Check if acct is matching format username@domain.com or @username@domain.com // User is local
const { username, domain } = parseUserAddress(acct); if (!domain || domain === config.http.base_url.host) {
const account = await User.fromSql(
and(eq(Users.username, username), isNull(Users.instanceId)),
);
if (account) {
return context.json(account.toApi(), 200);
}
return context.json(
{ error: `Account with username ${username} not found` },
404,
);
}
// User is remote
// Try to fetch it from database
const instance = await Instance.resolveFromHost(domain);
if (!instance) {
return context.json(
{ error: `Instance ${domain} not found` },
404,
);
}
// User is local
if (!domain || domain === config.http.base_url.host) {
const account = await User.fromSql( const account = await User.fromSql(
and(eq(Users.username, username), isNull(Users.instanceId)), and(
eq(Users.username, username),
eq(Users.instanceId, instance.id),
),
); );
if (account) { if (account) {
return context.json(account.toApi(), 200); return context.json(account.toApi(), 200);
} }
return context.json( // Fetch from remote instance
{ error: `Account with username ${username} not found` }, const manager = await (user ?? User).getFederationRequester();
404,
);
}
// User is remote const uri = await User.webFinger(manager, username, domain);
// Try to fetch it from database
const instance = await Instance.resolveFromHost(domain);
if (!instance) { if (!uri) {
return context.json({ error: `Instance ${domain} not found` }, 404); throw ApiError.accountNotFound();
} }
const account = await User.fromSql( const foundAccount = await User.resolve(uri);
and(
eq(Users.username, username),
eq(Users.instanceId, instance.id),
),
);
if (account) { if (foundAccount) {
return context.json(account.toApi(), 200); return context.json(foundAccount.toApi(), 200);
} }
// Fetch from remote instance
const manager = await (user ?? User).getFederationRequester();
const uri = await User.webFinger(manager, username, domain);
if (!uri) {
throw ApiError.accountNotFound(); throw ApiError.accountNotFound();
} },
),
const foundAccount = await User.resolve(uri);
if (foundAccount) {
return context.json(foundAccount.toApi(), 200);
}
throw ApiError.accountNotFound();
}),
); );

View file

@ -1,5 +1,4 @@
import { apiRoute, auth, qsQuery } from "@/api"; import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { import {
Account as AccountSchema, Account as AccountSchema,
Relationship as RelationshipSchema, Relationship as RelationshipSchema,
@ -7,20 +6,36 @@ import {
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db"; import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit"; import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/accounts/relationships", "/api/v1/accounts/relationships",
summary: "Check relationships to other accounts", describeRoute({
description: summary: "Check relationships to other accounts",
"Find out whether a given account is followed, blocked, muted, etc.", description:
externalDocs: { "Find out whether a given account is followed, blocked, muted, etc.",
url: "https://docs.joinmastodon.org/methods/accounts/#relationships", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/accounts/#relationships",
tags: ["Accounts"], },
middleware: [ tags: ["Accounts"],
responses: {
200: {
description: "Relationships",
content: {
"application/json": {
schema: resolver(z.array(RelationshipSchema)),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
rateLimit(10), rateLimit(10),
auth({ auth({
auth: true, auth: true,
@ -28,65 +43,53 @@ const route = createRoute({
permissions: [RolePermission.ManageOwnFollows], permissions: [RolePermission.ManageOwnFollows],
}), }),
qsQuery(), qsQuery(),
] as const, validator(
request: { "query",
query: z.object({ z.object({
id: z id: z
.array(AccountSchema.shape.id) .array(AccountSchema.shape.id)
.min(1) .min(1)
.max(10) .max(10)
.or(AccountSchema.shape.id.transform((v) => [v])) .or(AccountSchema.shape.id.transform((v) => [v]))
.openapi({ .openapi({
description:
"Check relationships for the provided account IDs.",
example: [
"f137ce6f-ff5e-4998-b20f-0361ba9be007",
"8424c654-5d03-4a1b-bec8-4e87db811b5d",
],
}),
with_suspended: zBoolean.default(false).openapi({
description: description:
"Check relationships for the provided account IDs.", "Whether relationships should be returned for suspended users",
example: [ example: false,
"f137ce6f-ff5e-4998-b20f-0361ba9be007",
"8424c654-5d03-4a1b-bec8-4e87db811b5d",
],
}), }),
with_suspended: zBoolean.default(false).openapi({
description:
"Whether relationships should be returned for suspended users",
example: false,
}), }),
}), handleZodError,
}, ),
responses: { async (context) => {
200: { const { user } = context.get("auth");
description: "Relationships",
content: { // TODO: Implement with_suspended
"application/json": { const { id } = context.req.valid("query");
schema: z.array(RelationshipSchema),
}, const ids = Array.isArray(id) ? id : [id];
},
const relationships = await Relationship.fromOwnerAndSubjects(
user,
ids,
);
relationships.sort(
(a, b) =>
ids.indexOf(a.data.subjectId) -
ids.indexOf(b.data.subjectId),
);
return context.json(
relationships.map((r) => r.toApi()),
200,
);
}, },
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
// TODO: Implement with_suspended
const { id } = context.req.valid("query");
const ids = Array.isArray(id) ? id : [id];
const relationships = await Relationship.fromOwnerAndSubjects(
user,
ids,
);
relationships.sort(
(a, b) =>
ids.indexOf(a.data.subjectId) - ids.indexOf(b.data.subjectId),
);
return context.json(
relationships.map((r) => r.toApi()),
200,
);
}),
); );

View file

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

View file

@ -1,27 +1,42 @@
import { apiRoute, auth, jsonOrForm } from "@/api"; import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { mergeAndDeduplicate } from "@/lib"; import { mergeAndDeduplicate } from "@/lib";
import { sanitizedHtmlStrip } from "@/sanitization"; import { sanitizedHtmlStrip } from "@/sanitization";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema, zBoolean } from "@versia/client/schemas"; import { Account as AccountSchema, zBoolean } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Emoji, Media, User } from "@versia/kit/db"; import { Emoji, Media, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml } from "~/classes/functions/status"; import { contentToHtml } from "~/classes/functions/status";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit"; import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({ export default apiRoute((app) =>
method: "patch", app.patch(
path: "/api/v1/accounts/update_credentials", "/api/v1/accounts/update_credentials",
summary: "Update account credentials", describeRoute({
description: "Update the users display and preferences.", summary: "Update account credentials",
externalDocs: { description: "Update the users display and preferences.",
url: "https://docs.joinmastodon.org/methods/accounts/#update_credentials", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/accounts/#update_credentials",
tags: ["Accounts"], },
middleware: [ tags: ["Accounts"],
responses: {
200: {
description: "Updated user",
content: {
"application/json": {
schema: resolver(AccountSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
rateLimit(5), rateLimit(5),
auth({ auth({
auth: true, auth: true,
@ -29,341 +44,314 @@ const route = createRoute({
scopes: ["write:accounts"], scopes: ["write:accounts"],
}), }),
jsonOrForm(), jsonOrForm(),
] as const, validator(
request: { "json",
body: { z
content: { .object({
"application/json": { display_name: AccountSchema.shape.display_name.openapi({
schema: z description: "The display name to use for the profile.",
.object({ example: "Lexi",
display_name: }),
AccountSchema.shape.display_name.openapi({ username: AccountSchema.shape.username.openapi({
description: description: "The username to use for the profile.",
"The display name to use for the profile.", example: "lexi",
example: "Lexi", }),
}), note: AccountSchema.shape.note.openapi({
username: AccountSchema.shape.username.openapi({ description: "The account bio. Markdown is supported.",
description: }),
"The username to use for the profile.", avatar: z
example: "lexi", .string()
}), .url()
note: AccountSchema.shape.note.openapi({ .transform((a) => new URL(a))
description: .openapi({
"The account bio. Markdown is supported.", description: "Avatar image URL",
}), })
avatar: z .or(
.string() z
.url() .instanceof(File)
.transform((a) => new URL(a)) .refine(
.openapi({ (v) =>
description: "Avatar image URL", v.size <=
}) config.validation.accounts
.or( .max_avatar_bytes,
z `Avatar must be less than ${config.validation.accounts.max_avatar_bytes} bytes`,
.instanceof(File)
.refine(
(v) =>
v.size <=
config.validation.accounts
.max_avatar_bytes,
`Avatar must be less than ${config.validation.accounts.max_avatar_bytes} bytes`,
)
.openapi({
description:
"Avatar image encoded using multipart/form-data",
}),
),
header: z
.string()
.url()
.transform((v) => new URL(v))
.openapi({
description: "Header image URL",
})
.or(
z
.instanceof(File)
.refine(
(v) =>
v.size <=
config.validation.accounts
.max_header_bytes,
`Header must be less than ${config.validation.accounts.max_header_bytes} bytes`,
)
.openapi({
description:
"Header image encoded using multipart/form-data",
}),
),
locked: AccountSchema.shape.locked.openapi({
description:
"Whether manual approval of follow requests is required.",
}),
bot: AccountSchema.shape.bot.openapi({
description:
"Whether the account has a bot flag.",
}),
discoverable:
AccountSchema.shape.discoverable.openapi({
description:
"Whether the account should be shown in the profile directory.",
}),
// TODO: Implement :(
hide_collections: zBoolean.openapi({
description:
"Whether to hide followers and followed accounts.",
}),
// TODO: Implement :(
indexable: zBoolean.openapi({
description:
"Whether public posts should be searchable to anyone.",
}),
// TODO: Implement :(
attribution_domains: z.array(z.string()).openapi({
description:
"Domains of websites allowed to credit the account.",
example: ["cnn.com", "myblog.com"],
}),
source: z
.object({
privacy:
AccountSchema.shape.source.unwrap()
.shape.privacy,
sensitive:
AccountSchema.shape.source.unwrap()
.shape.sensitive,
language:
AccountSchema.shape.source.unwrap()
.shape.language,
})
.partial(),
fields_attributes: z
.array(
z.object({
name: AccountSchema.shape.fields.element
.shape.name,
value: AccountSchema.shape.fields
.element.shape.value,
}),
) )
.max( .openapi({
config.validation.accounts.max_field_count, description:
), "Avatar image encoded using multipart/form-data",
}),
),
header: z
.string()
.url()
.transform((v) => new URL(v))
.openapi({
description: "Header image URL",
})
.or(
z
.instanceof(File)
.refine(
(v) =>
v.size <=
config.validation.accounts
.max_header_bytes,
`Header must be less than ${config.validation.accounts.max_header_bytes} bytes`,
)
.openapi({
description:
"Header image encoded using multipart/form-data",
}),
),
locked: AccountSchema.shape.locked.openapi({
description:
"Whether manual approval of follow requests is required.",
}),
bot: AccountSchema.shape.bot.openapi({
description: "Whether the account has a bot flag.",
}),
discoverable: AccountSchema.shape.discoverable.openapi({
description:
"Whether the account should be shown in the profile directory.",
}),
// TODO: Implement :(
hide_collections: zBoolean.openapi({
description:
"Whether to hide followers and followed accounts.",
}),
// TODO: Implement :(
indexable: zBoolean.openapi({
description:
"Whether public posts should be searchable to anyone.",
}),
// TODO: Implement :(
attribution_domains: z.array(z.string()).openapi({
description:
"Domains of websites allowed to credit the account.",
example: ["cnn.com", "myblog.com"],
}),
source: z
.object({
privacy:
AccountSchema.shape.source.unwrap().shape
.privacy,
sensitive:
AccountSchema.shape.source.unwrap().shape
.sensitive,
language:
AccountSchema.shape.source.unwrap().shape
.language,
}) })
.partial(), .partial(),
}, fields_attributes: z
}, .array(
}, z.object({
}, name: AccountSchema.shape.fields.element.shape
responses: { .name,
200: { value: AccountSchema.shape.fields.element.shape
description: "Updated user", .value,
content: { }),
"application/json": { )
schema: AccountSchema, .max(config.validation.accounts.max_field_count),
}, })
}, .partial(),
}, handleZodError,
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema, async (context) => {
}, const { user } = context.get("auth");
}); const {
display_name,
username,
note,
avatar,
header,
locked,
bot,
discoverable,
source,
fields_attributes,
} = context.req.valid("json");
export default apiRoute((app) => const self = user.data;
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const {
display_name,
username,
note,
avatar,
header,
locked,
bot,
discoverable,
source,
fields_attributes,
} = context.req.valid("json");
const self = user.data; const sanitizedDisplayName = await sanitizedHtmlStrip(
display_name ?? "",
const sanitizedDisplayName = await sanitizedHtmlStrip(
display_name ?? "",
);
if (display_name) {
self.displayName = sanitizedDisplayName;
}
if (note && self.source) {
self.source.note = note;
self.note = await contentToHtml({
"text/markdown": {
content: note,
remote: false,
},
});
}
if (source?.privacy) {
self.source.privacy = source.privacy;
}
if (source?.sensitive) {
self.source.sensitive = source.sensitive;
}
if (source?.language) {
self.source.language = source.language;
}
if (username) {
// Check if username is already taken
const existingUser = await User.fromSql(
and(isNull(Users.instanceId), eq(Users.username, username)),
); );
if (existingUser) { if (display_name) {
throw new ApiError(422, "Username is already taken"); self.displayName = sanitizedDisplayName;
} }
self.username = username; if (note && self.source) {
} self.source.note = note;
self.note = await contentToHtml({
if (avatar) { "text/markdown": {
if (avatar instanceof File) { content: note,
if (user.avatar) { remote: false,
await user.avatar.updateFromFile(avatar);
} else {
user.avatar = await Media.fromFile(avatar);
}
} else if (user.avatar) {
await user.avatar.updateFromUrl(avatar);
} else {
user.avatar = await Media.fromUrl(avatar);
}
}
if (header) {
if (header instanceof File) {
if (user.header) {
await user.header.updateFromFile(header);
} else {
user.header = await Media.fromFile(header);
}
} else if (user.header) {
await user.header.updateFromUrl(header);
} else {
user.header = await Media.fromUrl(header);
}
}
if (locked) {
self.isLocked = locked;
}
if (bot) {
self.isBot = bot;
}
if (discoverable) {
self.isDiscoverable = discoverable;
}
const fieldEmojis: Emoji[] = [];
if (fields_attributes) {
self.fields = [];
self.source.fields = [];
for (const field of fields_attributes) {
// Can be Markdown or plaintext, also has emojis
const parsedName = await contentToHtml(
{
"text/markdown": {
content: field.name,
remote: false,
},
},
undefined,
true,
);
const parsedValue = await contentToHtml(
{
"text/markdown": {
content: field.value,
remote: false,
},
},
undefined,
true,
);
// Parse emojis
const nameEmojis = await Emoji.parseFromText(parsedName);
const valueEmojis = await Emoji.parseFromText(parsedValue);
fieldEmojis.push(...nameEmojis, ...valueEmojis);
// Replace fields
self.fields.push({
key: {
"text/html": {
content: parsedName,
remote: false,
},
},
value: {
"text/html": {
content: parsedValue,
remote: false,
},
}, },
}); });
self.source.fields.push({
name: field.name,
value: field.value,
verified_at: null,
});
} }
}
// Parse emojis if (source?.privacy) {
const displaynameEmojis = self.source.privacy = source.privacy;
await Emoji.parseFromText(sanitizedDisplayName); }
const noteEmojis = await Emoji.parseFromText(self.note);
const emojis = mergeAndDeduplicate( if (source?.sensitive) {
displaynameEmojis, self.source.sensitive = source.sensitive;
noteEmojis, }
fieldEmojis,
);
// Connect emojis, if any if (source?.language) {
// Do it before updating user, so that federation takes that into account self.source.language = source.language;
await user.updateEmojis(emojis); }
await user.update({
displayName: self.displayName,
username: self.username,
note: self.note,
avatar: self.avatar,
avatarId: user.avatar?.id,
header: self.header,
headerId: user.header?.id,
fields: self.fields,
isLocked: self.isLocked,
isBot: self.isBot,
isDiscoverable: self.isDiscoverable,
source: self.source || undefined,
});
const output = await User.fromId(self.id); if (username) {
// Check if username is already taken
const existingUser = await User.fromSql(
and(isNull(Users.instanceId), eq(Users.username, username)),
);
if (!output) { if (existingUser) {
throw new ApiError(500, "Couldn't edit user"); throw new ApiError(422, "Username is already taken");
} }
return context.json(output.toApi(), 200); self.username = username;
}), }
if (avatar) {
if (avatar instanceof File) {
if (user.avatar) {
await user.avatar.updateFromFile(avatar);
} else {
user.avatar = await Media.fromFile(avatar);
}
} else if (user.avatar) {
await user.avatar.updateFromUrl(avatar);
} else {
user.avatar = await Media.fromUrl(avatar);
}
}
if (header) {
if (header instanceof File) {
if (user.header) {
await user.header.updateFromFile(header);
} else {
user.header = await Media.fromFile(header);
}
} else if (user.header) {
await user.header.updateFromUrl(header);
} else {
user.header = await Media.fromUrl(header);
}
}
if (locked) {
self.isLocked = locked;
}
if (bot) {
self.isBot = bot;
}
if (discoverable) {
self.isDiscoverable = discoverable;
}
const fieldEmojis: Emoji[] = [];
if (fields_attributes) {
self.fields = [];
self.source.fields = [];
for (const field of fields_attributes) {
// Can be Markdown or plaintext, also has emojis
const parsedName = await contentToHtml(
{
"text/markdown": {
content: field.name,
remote: false,
},
},
undefined,
true,
);
const parsedValue = await contentToHtml(
{
"text/markdown": {
content: field.value,
remote: false,
},
},
undefined,
true,
);
// Parse emojis
const nameEmojis = await Emoji.parseFromText(parsedName);
const valueEmojis = await Emoji.parseFromText(parsedValue);
fieldEmojis.push(...nameEmojis, ...valueEmojis);
// Replace fields
self.fields.push({
key: {
"text/html": {
content: parsedName,
remote: false,
},
},
value: {
"text/html": {
content: parsedValue,
remote: false,
},
},
});
self.source.fields.push({
name: field.name,
value: field.value,
verified_at: null,
});
}
}
// Parse emojis
const displaynameEmojis =
await Emoji.parseFromText(sanitizedDisplayName);
const noteEmojis = await Emoji.parseFromText(self.note);
const emojis = mergeAndDeduplicate(
displaynameEmojis,
noteEmojis,
fieldEmojis,
);
// Connect emojis, if any
// Do it before updating user, so that federation takes that into account
await user.updateEmojis(emojis);
await user.update({
displayName: self.displayName,
username: self.username,
note: self.note,
avatar: self.avatar,
avatarId: user.avatar?.id,
header: self.header,
headerId: user.header?.id,
fields: self.fields,
isLocked: self.isLocked,
isBot: self.isBot,
isDiscoverable: self.isDiscoverable,
source: self.source || undefined,
});
const output = await User.fromId(self.id);
if (!output) {
throw new ApiError(500, "Couldn't edit user");
}
return context.json(output.toApi(), 200);
},
),
); );

View file

@ -1,44 +1,43 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Account } from "@versia/client/schemas"; import { Account } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/accounts/verify_credentials", "/api/v1/accounts/verify_credentials",
summary: "Verify account credentials", describeRoute({
description: "Test to make sure that the user token works.", summary: "Verify account credentials",
externalDocs: { description: "Test to make sure that the user token works.",
url: "https://docs.joinmastodon.org/methods/accounts/#verify_credentials", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/accounts/#verify_credentials",
tags: ["Accounts"], },
middleware: [ tags: ["Accounts"],
responses: {
200: {
// TODO: Implement CredentialAccount
description:
"Note the extra source property, which is not visible on accounts other than your own. Also note that plain-text is used within source and HTML is used for their corresponding properties such as note and fields.",
content: {
"application/json": {
schema: resolver(Account),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
scopes: ["read:accounts"], scopes: ["read:accounts"],
}), }),
] as const, (context) => {
responses: { // TODO: Add checks for disabled/unverified accounts
200: { const { user } = context.get("auth");
// TODO: Implement CredentialAccount
description: return context.json(user.toApi(true), 200);
"Note the extra source property, which is not visible on accounts other than your own. Also note that plain-text is used within source and HTML is used for their corresponding properties such as note and fields.",
content: {
"application/json": {
schema: Account,
},
},
}, },
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, (context) => {
// TODO: Add checks for disabled/unverified accounts
const { user } = context.get("auth");
return context.json(user.toApi(true), 200);
}),
); );

View file

@ -1,80 +1,80 @@
import { apiRoute, jsonOrForm } from "@/api"; import { apiRoute, handleZodError, jsonOrForm } from "@/api";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { createRoute, z } from "@hono/zod-openapi";
import { import {
Application as ApplicationSchema, Application as ApplicationSchema,
CredentialApplication as CredentialApplicationSchema, CredentialApplication as CredentialApplicationSchema,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Application } from "@versia/kit/db"; import { Application } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit"; import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({
method: "post",
path: "/api/v1/apps",
summary: "Create an application",
description: "Create a new application to obtain OAuth2 credentials.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/apps/#create",
},
tags: ["Apps"],
middleware: [jsonOrForm(), rateLimit(4)],
request: {
body: {
content: {
"application/json": {
schema: z.object({
client_name: ApplicationSchema.shape.name,
redirect_uris: ApplicationSchema.shape.redirect_uris.or(
ApplicationSchema.shape.redirect_uri.transform(
(u) => u.split("\n"),
),
),
scopes: z
.string()
.default("read")
.transform((s) => s.split(" "))
.openapi({
description: "Space separated list of scopes.",
}),
// Allow empty websites because Traewelling decides to give an empty
// value instead of not providing anything at all
website: ApplicationSchema.shape.website
.optional()
.or(z.literal("").transform(() => undefined)),
}),
},
},
},
},
responses: {
200: {
description:
"Store the client_id and client_secret in your cache, as these will be used to obtain OAuth tokens.",
content: {
"application/json": {
schema: CredentialApplicationSchema,
},
},
},
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.post(
const { client_name, redirect_uris, scopes, website } = "/api/v1/apps",
context.req.valid("json"); describeRoute({
summary: "Create an application",
description:
"Create a new application to obtain OAuth2 credentials.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/apps/#create",
},
tags: ["Apps"],
responses: {
200: {
description:
"Store the client_id and client_secret in your cache, as these will be used to obtain OAuth tokens.",
content: {
"application/json": {
schema: resolver(CredentialApplicationSchema),
},
},
},
422: ApiError.validationFailed().schema,
},
}),
jsonOrForm(),
rateLimit(4),
validator(
"json",
z.object({
client_name: ApplicationSchema.shape.name,
redirect_uris: ApplicationSchema.shape.redirect_uris.or(
ApplicationSchema.shape.redirect_uri.transform((u) =>
u.split("\n"),
),
),
scopes: z
.string()
.default("read")
.transform((s) => s.split(" "))
.openapi({
description: "Space separated list of scopes.",
}),
// Allow empty websites because Traewelling decides to give an empty
// value instead of not providing anything at all
website: ApplicationSchema.shape.website
.optional()
.or(z.literal("").transform(() => undefined)),
}),
handleZodError,
),
async (context) => {
const { client_name, redirect_uris, scopes, website } =
context.req.valid("json");
const app = await Application.insert({ const app = await Application.insert({
name: client_name, name: client_name,
redirectUri: redirect_uris.join("\n"), redirectUri: redirect_uris.join("\n"),
scopes: scopes.join(" "), scopes: scopes.join(" "),
website, website,
clientId: randomString(32, "base64url"), clientId: randomString(32, "base64url"),
secret: randomString(64, "base64url"), secret: randomString(64, "base64url"),
}); });
return context.json(app.toApiCredential(), 200); return context.json(app.toApiCredential(), 200);
}), },
),
); );

View file

@ -1,52 +1,51 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Application as ApplicationSchema } from "@versia/client/schemas"; import { Application as ApplicationSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Application } from "@versia/kit/db"; import { Application } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/apps/verify_credentials", "/api/v1/apps/verify_credentials",
summary: "Verify your app works", describeRoute({
description: "Confirm that the apps OAuth2 credentials work.", summary: "Verify your app works",
externalDocs: { description: "Confirm that the apps OAuth2 credentials work.",
url: "https://docs.joinmastodon.org/methods/apps/#verify_credentials", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/apps/#verify_credentials",
tags: ["Apps"], },
middleware: [ tags: ["Apps"],
responses: {
200: {
description:
"If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.",
content: {
"application/json": {
schema: resolver(ApplicationSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
permissions: [RolePermission.ManageOwnApps], permissions: [RolePermission.ManageOwnApps],
}), }),
] as const, async (context) => {
responses: { const { token } = context.get("auth");
200: {
description: const application = await Application.getFromToken(
"If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.", token.data.accessToken,
content: { );
"application/json": {
schema: ApplicationSchema, if (!application) {
}, throw ApiError.applicationNotFound();
}, }
return context.json(application.toApi(), 200);
}, },
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { token } = context.get("auth");
const application = await Application.getFromToken(
token.data.accessToken,
);
if (!application) {
throw ApiError.applicationNotFound();
}
return context.json(application.toApi(), 200);
}),
); );

View file

@ -1,100 +1,110 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas"; import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/blocks", "/api/v1/blocks",
summary: "View your blocks.", describeRoute({
description: "View blocked users.", summary: "View your blocks.",
externalDocs: { description: "View blocked users.",
url: "https://docs.joinmastodon.org/methods/blocks/#get", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/blocks/#get",
tags: ["Blocks"], },
middleware: [ tags: ["Blocks"],
responses: {
200: {
description: "List of blocked users",
content: {
"application/json": {
schema: resolver(z.array(AccountSchema)),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({
description:
"Links to the next and previous pages",
example:
'<https://versia.social/api/v1/blocks?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/blocks?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
scopes: ["read:blocks"], scopes: ["read:blocks"],
permissions: [RolePermission.ManageOwnBlocks], permissions: [RolePermission.ManageOwnBlocks],
}), }),
] as const, validator(
request: { "query",
query: z.object({ z.object({
max_id: AccountSchema.shape.id.optional().openapi({ max_id: AccountSchema.shape.id.optional().openapi({
description: description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.", "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}), }),
since_id: AccountSchema.shape.id.optional().openapi({ since_id: AccountSchema.shape.id.optional().openapi({
description: description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.", "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined, example: undefined,
}), }),
min_id: AccountSchema.shape.id.optional().openapi({ min_id: AccountSchema.shape.id.optional().openapi({
description: description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined, example: undefined,
}), }),
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ limit: z.coerce
description: "Maximum number of results to return.", .number()
}), .int()
}), .min(1)
}, .max(80)
responses: { .default(40)
200: {
description: "List of blocked users",
content: {
"application/json": {
schema: z.array(AccountSchema),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({ .openapi({
description: "Links to the next and previous pages", description: "Maximum number of results to return.",
example:
'<https://versia.social/api/v1/blocks?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/blocks?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}), }),
}), }),
handleZodError,
),
async (context) => {
const { max_id, since_id, min_id, limit } =
context.req.valid("query");
const { user } = context.get("auth");
const { objects: blocks, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
),
limit,
new URL(context.req.url),
);
return context.json(
blocks.map((u) => u.toApi()),
200,
{
Link: link,
},
);
}, },
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { max_id, since_id, min_id, limit } = context.req.valid("query");
const { user } = context.get("auth");
const { objects: blocks, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
),
limit,
new URL(context.req.url),
);
return context.json(
blocks.map((u) => u.toApi()),
200,
{
Link: link,
},
);
}),
); );

View file

@ -1,55 +1,54 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { generateChallenge } from "@/challenges"; import { generateChallenge } from "@/challenges";
import { createRoute } from "@hono/zod-openapi";
import { Challenge } from "@versia/client/schemas"; import { Challenge } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const route = createRoute({ export default apiRoute((app) =>
method: "post", app.post(
path: "/api/v1/challenges", "/api/v1/challenges",
summary: "Generate a challenge", describeRoute({
description: "Generate a challenge to solve", summary: "Generate a challenge",
tags: ["Challenges"], description: "Generate a challenge to solve",
middleware: [ tags: ["Challenges"],
responses: {
200: {
description: "Challenge",
content: {
"application/json": {
schema: resolver(Challenge),
},
},
},
400: {
description: "Challenges are disabled",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
},
}),
auth({ auth({
auth: false, auth: false,
}), }),
] as const, async (context) => {
responses: { if (!config.validation.challenges) {
200: { throw new ApiError(400, "Challenges are disabled in config");
description: "Challenge", }
content: {
"application/json": { const result = await generateChallenge();
schema: Challenge,
return context.json(
{
id: result.id,
...result.challenge,
}, },
}, 200,
);
}, },
400: { ),
description: "Challenges are disabled",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
if (!config.validation.challenges) {
throw new ApiError(400, "Challenges are disabled in config");
}
const result = await generateChallenge();
return context.json(
{
id: result.id,
...result.challenge,
},
200,
);
}),
); );

View file

@ -1,57 +1,58 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas"; import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Emoji } from "@versia/kit/db"; import { Emoji } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables"; import { Emojis } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/custom_emojis", "/api/v1/custom_emojis",
summary: "View all custom emoji", describeRoute({
description: "Returns custom emojis that are available on the server.", summary: "View all custom emoji",
externalDocs: { description:
url: "https://docs.joinmastodon.org/methods/custom_emojis/#get", "Returns custom emojis that are available on the server.",
}, externalDocs: {
tags: ["Emojis"], url: "https://docs.joinmastodon.org/methods/custom_emojis/#get",
middleware: [ },
tags: ["Emojis"],
responses: {
200: {
description: "List of custom emojis",
content: {
"application/json": {
schema: resolver(z.array(CustomEmojiSchema)),
},
},
},
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: false, auth: false,
permissions: [RolePermission.ViewEmojis], permissions: [RolePermission.ViewEmojis],
}), }),
] as const, async (context) => {
responses: { const { user } = context.get("auth");
200: {
description: "List of custom emojis",
content: {
"application/json": {
schema: z.array(CustomEmojiSchema),
},
},
},
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) => const emojis = await Emoji.manyFromSql(
app.openapi(route, async (context) => { and(
const { user } = context.get("auth"); isNull(Emojis.instanceId),
or(
const emojis = await Emoji.manyFromSql( isNull(Emojis.ownerId),
and( user ? eq(Emojis.ownerId, user.id) : undefined,
isNull(Emojis.instanceId), ),
or(
isNull(Emojis.ownerId),
user ? eq(Emojis.ownerId, user.id) : undefined,
), ),
), );
);
return context.json( return context.json(
emojis.map((emoji) => emoji.toApi()), emojis.map((emoji) => emoji.toApi()),
200, 200,
); );
}), },
),
); );

View file

@ -1,276 +0,0 @@
import { apiRoute, auth, jsonOrForm, withEmojiParam } from "@/api";
import { mimeLookup } from "@/content_types";
import { createRoute, z } from "@hono/zod-openapi";
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
const schema = z
.object({
shortcode: CustomEmojiSchema.shape.shortcode,
element: z
.string()
.url()
.transform((a) => new URL(a))
.openapi({
description: "Emoji image URL",
})
.or(
z
.instanceof(File)
.openapi({
description:
"Emoji image encoded using multipart/form-data",
})
.refine(
(v) => v.size <= config.validation.emojis.max_bytes,
`Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
),
),
category: CustomEmojiSchema.shape.category.optional(),
alt: CustomEmojiSchema.shape.description.optional(),
global: CustomEmojiSchema.shape.global.default(false),
})
.partial();
const routeGet = createRoute({
method: "get",
path: "/api/v1/emojis/{id}",
summary: "Get emoji",
description: "Retrieves a custom emoji from database by ID.",
tags: ["Emojis"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.ViewEmojis],
}),
withEmojiParam,
] as const,
request: {
params: z.object({
id: CustomEmojiSchema.shape.id,
}),
},
responses: {
200: {
description: "Emoji",
content: {
"application/json": {
schema: CustomEmojiSchema,
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
const routePatch = createRoute({
method: "patch",
path: "/api/v1/emojis/{id}",
summary: "Modify emoji",
description: "Edit image or metadata of an emoji.",
tags: ["Emojis"],
middleware: [
auth({
auth: true,
permissions: [
RolePermission.ManageOwnEmojis,
RolePermission.ViewEmojis,
],
}),
jsonOrForm(),
withEmojiParam,
] as const,
request: {
params: z.object({
id: CustomEmojiSchema.shape.id,
}),
body: {
content: {
"application/json": {
schema,
},
"application/x-www-form-urlencoded": {
schema,
},
"multipart/form-data": {
schema,
},
},
},
},
responses: {
200: {
description: "Emoji modified",
content: {
"application/json": {
schema: CustomEmojiSchema,
},
},
},
403: {
description: "Insufficient permissions",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
const routeDelete = createRoute({
method: "delete",
path: "/api/v1/emojis/{id}",
summary: "Delete emoji",
description: "Delete a custom emoji from the database.",
tags: ["Emojis"],
middleware: [
auth({
auth: true,
permissions: [
RolePermission.ManageOwnEmojis,
RolePermission.ViewEmojis,
],
}),
withEmojiParam,
] as const,
request: {
params: z.object({
id: CustomEmojiSchema.shape.id,
}),
},
responses: {
204: {
description: "Emoji deleted",
},
404: ApiError.emojiNotFound().schema,
},
});
export default apiRoute((app) => {
app.openapi(routeGet, (context) => {
const { user } = context.get("auth");
const emoji = context.get("emoji");
// Don't leak non-global emojis to non-admins
if (
!user.hasPermission(RolePermission.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
throw ApiError.emojiNotFound();
}
return context.json(emoji.toApi(), 200);
});
app.openapi(routePatch, async (context) => {
const { user } = context.get("auth");
const emoji = context.get("emoji");
// Check if user is admin
if (
!user.hasPermission(RolePermission.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
throw new ApiError(
403,
"Cannot modify emoji not owned by you",
`This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
);
}
const {
global: emojiGlobal,
alt,
category,
element,
shortcode,
} = context.req.valid("json");
if (!user.hasPermission(RolePermission.ManageEmojis) && emojiGlobal) {
throw new ApiError(
401,
"Missing permissions",
`'${RolePermission.ManageEmojis}' permission is needed to upload global emojis`,
);
}
if (element) {
// Check if emoji is an image
const contentType =
element instanceof File
? element.type
: await mimeLookup(element);
if (!contentType.startsWith("image/")) {
throw new ApiError(
422,
"Invalid content type",
`Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
);
}
if (element instanceof File) {
await emoji.media.updateFromFile(element);
} else {
await emoji.media.updateFromUrl(element);
}
}
if (alt) {
await emoji.media.updateMetadata({
description: alt,
});
}
await emoji.update({
shortcode,
ownerId: emojiGlobal ? null : user.data.id,
category,
});
return context.json(emoji.toApi(), 200);
});
app.openapi(routeDelete, async (context) => {
const { user } = context.get("auth");
const emoji = context.get("emoji");
// Check if user is admin
if (
!user.hasPermission(RolePermission.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
throw new ApiError(
403,
"Cannot delete emoji not owned by you",
`This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
);
}
await emoji.delete();
return context.body(null, 204);
});
});

View file

@ -0,0 +1,258 @@
import {
apiRoute,
auth,
handleZodError,
jsonOrForm,
withEmojiParam,
} from "@/api";
import { mimeLookup } from "@/content_types";
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
export default apiRoute((app) => {
app.get(
"/api/v1/emojis/:id",
describeRoute({
summary: "Get emoji",
description: "Retrieves a custom emoji from database by ID.",
tags: ["Emojis"],
responses: {
200: {
description: "Emoji",
content: {
"application/json": {
schema: resolver(CustomEmojiSchema),
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({
auth: true,
permissions: [RolePermission.ViewEmojis],
}),
withEmojiParam,
(context) => {
const { user } = context.get("auth");
const emoji = context.get("emoji");
// Don't leak non-global emojis to non-admins
if (
!user.hasPermission(RolePermission.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
throw ApiError.emojiNotFound();
}
return context.json(emoji.toApi(), 200);
},
);
app.patch(
"/api/v1/emojis/:id",
describeRoute({
summary: "Modify emoji",
description: "Edit image or metadata of an emoji.",
tags: ["Emojis"],
responses: {
200: {
description: "Emoji modified",
content: {
"application/json": {
schema: resolver(CustomEmojiSchema),
},
},
},
403: {
description: "Insufficient permissions",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({
auth: true,
permissions: [
RolePermission.ManageOwnEmojis,
RolePermission.ViewEmojis,
],
}),
jsonOrForm(),
withEmojiParam,
validator(
"json",
z
.object({
shortcode: CustomEmojiSchema.shape.shortcode,
element: z
.string()
.url()
.transform((a) => new URL(a))
.openapi({
description: "Emoji image URL",
})
.or(
z
.instanceof(File)
.openapi({
description:
"Emoji image encoded using multipart/form-data",
})
.refine(
(v) =>
v.size <=
config.validation.emojis.max_bytes,
`Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
),
),
category: CustomEmojiSchema.shape.category.optional(),
alt: CustomEmojiSchema.shape.description.optional(),
global: CustomEmojiSchema.shape.global.default(false),
})
.partial(),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const emoji = context.get("emoji");
// Check if user is admin
if (
!user.hasPermission(RolePermission.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
throw new ApiError(
403,
"Cannot modify emoji not owned by you",
`This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
);
}
const {
global: emojiGlobal,
alt,
category,
element,
shortcode,
} = context.req.valid("json");
if (
!user.hasPermission(RolePermission.ManageEmojis) &&
emojiGlobal
) {
throw new ApiError(
401,
"Missing permissions",
`'${RolePermission.ManageEmojis}' permission is needed to upload global emojis`,
);
}
if (element) {
// Check if emoji is an image
const contentType =
element instanceof File
? element.type
: await mimeLookup(element);
if (!contentType.startsWith("image/")) {
throw new ApiError(
422,
"Invalid content type",
`Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
);
}
if (element instanceof File) {
await emoji.media.updateFromFile(element);
} else {
await emoji.media.updateFromUrl(element);
}
}
if (alt) {
await emoji.media.updateMetadata({
description: alt,
});
}
await emoji.update({
shortcode,
ownerId: emojiGlobal ? null : user.data.id,
category,
});
return context.json(emoji.toApi(), 200);
},
);
app.delete(
"/api/v1/emojis/{id}",
describeRoute({
summary: "Delete emoji",
description: "Delete a custom emoji from the database.",
tags: ["Emojis"],
responses: {
204: {
description: "Emoji deleted",
},
404: ApiError.emojiNotFound().schema,
},
}),
auth({
auth: true,
permissions: [
RolePermission.ManageOwnEmojis,
RolePermission.ViewEmojis,
],
}),
withEmojiParam,
async (context) => {
const { user } = context.get("auth");
const emoji = context.get("emoji");
// Check if user is admin
if (
!user.hasPermission(RolePermission.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
throw new ApiError(
403,
"Cannot delete emoji not owned by you",
`This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
);
}
await emoji.delete();
return context.body(null, 204);
},
);
});

View file

@ -1,47 +1,36 @@
import { apiRoute, auth, jsonOrForm } from "@/api"; import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { mimeLookup } from "@/content_types"; import { mimeLookup } from "@/content_types";
import { createRoute, z } from "@hono/zod-openapi";
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas"; import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Emoji, Media } from "@versia/kit/db"; import { Emoji, Media } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables"; import { Emojis } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const schema = z.object({ export default apiRoute((app) =>
shortcode: CustomEmojiSchema.shape.shortcode, app.post(
element: z "/api/v1/emojis",
.string() describeRoute({
.url() summary: "Upload emoji",
.transform((a) => new URL(a)) description: "Upload a new emoji to the server.",
.openapi({ tags: ["Emojis"],
description: "Emoji image URL", responses: {
}) 201: {
.or( description: "Uploaded emoji",
z content: {
.instanceof(File) "application/json": {
.openapi({ schema: resolver(CustomEmojiSchema),
description: },
"Emoji image encoded using multipart/form-data", },
}) },
.refine( 401: ApiError.missingAuthentication().schema,
(v) => v.size <= config.validation.emojis.max_bytes, 422: ApiError.validationFailed().schema,
`Emoji must be less than ${config.validation.emojis.max_bytes} bytes`, },
), }),
),
category: CustomEmojiSchema.shape.category.optional(),
alt: CustomEmojiSchema.shape.description.optional(),
global: CustomEmojiSchema.shape.global.default(false),
});
const route = createRoute({
method: "post",
path: "/api/v1/emojis",
summary: "Upload emoji",
description: "Upload a new emoji to the server.",
tags: ["Emojis"],
middleware: [
auth({ auth({
auth: true, auth: true,
permissions: [ permissions: [
@ -50,96 +39,99 @@ const route = createRoute({
], ],
}), }),
jsonOrForm(), jsonOrForm(),
] as const, validator(
request: { "json",
body: { z.object({
content: { shortcode: CustomEmojiSchema.shape.shortcode,
"application/json": { element: z
schema, .string()
}, .url()
"multipart/form-data": { .transform((a) => new URL(a))
schema, .openapi({
}, description: "Emoji image URL",
"application/x-www-form-urlencoded": { })
schema, .or(
}, z
}, .instanceof(File)
.openapi({
description:
"Emoji image encoded using multipart/form-data",
})
.refine(
(v) =>
v.size <=
config.validation.emojis.max_bytes,
`Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
),
),
category: CustomEmojiSchema.shape.category.optional(),
alt: CustomEmojiSchema.shape.description.optional(),
global: CustomEmojiSchema.shape.global.default(false),
}),
handleZodError,
),
async (context) => {
const { shortcode, element, alt, global, category } =
context.req.valid("json");
const { user } = context.get("auth");
if (!user.hasPermission(RolePermission.ManageEmojis) && global) {
throw new ApiError(
401,
"Missing permissions",
`Only users with the '${RolePermission.ManageEmojis}' permission can upload global emojis`,
);
}
// Check if emoji already exists
const existing = await Emoji.fromSql(
and(
eq(Emojis.shortcode, shortcode),
isNull(Emojis.instanceId),
or(eq(Emojis.ownerId, user.id), isNull(Emojis.ownerId)),
),
);
if (existing) {
throw new ApiError(
422,
"Emoji already exists",
`An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
);
}
// Check of emoji is an image
const contentType =
element instanceof File
? element.type
: await mimeLookup(element);
if (!contentType.startsWith("image/")) {
throw new ApiError(
422,
"Invalid content type",
`Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
);
}
const media =
element instanceof File
? await Media.fromFile(element, {
description: alt ?? undefined,
})
: await Media.fromUrl(element, {
description: alt ?? undefined,
});
const emoji = await Emoji.insert({
shortcode,
mediaId: media.id,
visibleInPicker: true,
ownerId: global ? null : user.id,
category,
});
return context.json(emoji.toApi(), 201);
}, },
}, ),
responses: {
201: {
description: "Uploaded emoji",
content: {
"application/json": {
schema: CustomEmojiSchema,
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { shortcode, element, alt, global, category } =
context.req.valid("json");
const { user } = context.get("auth");
if (!user.hasPermission(RolePermission.ManageEmojis) && global) {
throw new ApiError(
401,
"Missing permissions",
`Only users with the '${RolePermission.ManageEmojis}' permission can upload global emojis`,
);
}
// Check if emoji already exists
const existing = await Emoji.fromSql(
and(
eq(Emojis.shortcode, shortcode),
isNull(Emojis.instanceId),
or(eq(Emojis.ownerId, user.id), isNull(Emojis.ownerId)),
),
);
if (existing) {
throw new ApiError(
422,
"Emoji already exists",
`An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
);
}
// Check of emoji is an image
const contentType =
element instanceof File ? element.type : await mimeLookup(element);
if (!contentType.startsWith("image/")) {
throw new ApiError(
422,
"Invalid content type",
`Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
);
}
const media =
element instanceof File
? await Media.fromFile(element, {
description: alt ?? undefined,
})
: await Media.fromUrl(element, {
description: alt ?? undefined,
});
const emoji = await Emoji.insert({
shortcode,
mediaId: media.id,
visibleInPicker: true,
ownerId: global ? null : user.id,
category,
});
return context.json(emoji.toApi(), 201);
}),
); );

View file

@ -1,100 +1,111 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Status as StatusSchema } from "@versia/client/schemas"; import { Status as StatusSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables"; import { Notes } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/favourites", "/api/v1/favourites",
summary: "View favourited statuses", describeRoute({
description: "Statuses the user has favourited.", summary: "View favourited statuses",
externalDocs: { description: "Statuses the user has favourited.",
url: "https://docs.joinmastodon.org/methods/favourites/#get", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/favourites/#get",
tags: ["Favourites"], },
middleware: [ tags: ["Favourites"],
responses: {
200: {
description: "List of favourited statuses",
content: {
"application/json": {
schema: resolver(z.array(StatusSchema)),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({
description:
"Links to the next and previous pages",
example:
'<https://versia.social/api/v1/favourites?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/favourites?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
permissions: [RolePermission.ManageOwnLikes], permissions: [RolePermission.ManageOwnLikes],
}), }),
] as const, validator(
request: { "query",
query: z.object({ z.object({
max_id: StatusSchema.shape.id.optional().openapi({ max_id: StatusSchema.shape.id.optional().openapi({
description: description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.", "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}), }),
since_id: StatusSchema.shape.id.optional().openapi({ since_id: StatusSchema.shape.id.optional().openapi({
description: description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.", "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined, example: undefined,
}), }),
min_id: StatusSchema.shape.id.optional().openapi({ min_id: StatusSchema.shape.id.optional().openapi({
description: description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined, example: undefined,
}), }),
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ limit: z.coerce
description: "Maximum number of results to return.", .number()
}), .int()
}), .min(1)
}, .max(80)
responses: { .default(40)
200: {
description: "List of favourited statuses",
content: {
"application/json": {
schema: z.array(StatusSchema),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({ .openapi({
description: "Links to the next and previous pages", description: "Maximum number of results to return.",
example:
'<https://versia.social/api/v1/favourites?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/favourites?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}), }),
}), }),
handleZodError,
),
async (context) => {
const { max_id, since_id, min_id, limit } =
context.req.valid("query");
const { user } = context.get("auth");
const { objects: favourites, link } =
await Timeline.getNoteTimeline(
and(
max_id ? lt(Notes.id, max_id) : undefined,
since_id ? gte(Notes.id, since_id) : undefined,
min_id ? gt(Notes.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
),
limit,
new URL(context.req.url),
user?.id,
);
return context.json(
await Promise.all(favourites.map((note) => note.toApi(user))),
200,
{
Link: link,
},
);
}, },
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { max_id, since_id, min_id, limit } = context.req.valid("query");
const { user } = context.get("auth");
const { objects: favourites, link } = await Timeline.getNoteTimeline(
and(
max_id ? lt(Notes.id, max_id) : undefined,
since_id ? gte(Notes.id, since_id) : undefined,
min_id ? gt(Notes.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
),
limit,
new URL(context.req.url),
user?.id,
);
return context.json(
await Promise.all(favourites.map((note) => note.toApi(user))),
200,
{
Link: link,
},
);
}),
); );

View file

@ -1,81 +0,0 @@
import { apiRoute, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship, User } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/follow_requests/{account_id}/authorize",
summary: "Accept follow request",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/follow_requests/#accept",
},
tags: ["Follows"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.ManageOwnFollows],
}),
] as const,
request: {
params: z.object({
account_id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description:
"Your Relationship with this account should be updated so that you are followed_by this account.",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const { account_id } = context.req.valid("param");
const account = await User.fromId(account_id);
if (!account) {
throw ApiError.accountNotFound();
}
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
account,
user,
);
await oppositeRelationship.update({
requested: false,
following: true,
});
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
account,
);
// Check if accepting remote follow
if (account.isRemote()) {
// Federate follow accept
await user.sendFollowAccept(account);
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -1,81 +0,0 @@
import { apiRoute, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship, User } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/follow_requests/{account_id}/reject",
summary: "Reject follow request",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/follow_requests/#reject",
},
tags: ["Follows"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.ManageOwnFollows],
}),
] as const,
request: {
params: z.object({
account_id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description:
"Your Relationship with this account should be unchanged.",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const { account_id } = context.req.valid("param");
const account = await User.fromId(account_id);
if (!account) {
throw ApiError.accountNotFound();
}
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
account,
user,
);
await oppositeRelationship.update({
requested: false,
following: false,
});
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
account,
);
// Check if rejecting remote follow
if (account.isRemote()) {
// Federate follow reject
await user.sendFollowReject(account);
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,82 @@
import { apiRoute, auth, handleZodError } from "@/api";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship, User } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/follow_requests/:account_id/authorize",
describeRoute({
summary: "Accept follow request",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/follow_requests/#accept",
},
tags: ["Follows"],
responses: {
200: {
description:
"Your Relationship with this account should be updated so that you are followed_by this account.",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({
auth: true,
permissions: [RolePermission.ManageOwnFollows],
}),
validator(
"param",
z.object({
account_id: AccountSchema.shape.id,
}),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const { account_id } = context.req.valid("param");
const account = await User.fromId(account_id);
if (!account) {
throw ApiError.accountNotFound();
}
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
account,
user,
);
await oppositeRelationship.update({
requested: false,
following: true,
});
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
account,
);
// Check if accepting remote follow
if (account.isRemote()) {
// Federate follow accept
await user.sendFollowAccept(account);
}
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,83 @@
import { apiRoute, auth, handleZodError } from "@/api";
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
} from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Relationship, User } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/follow_requests/:account_id/reject",
describeRoute({
summary: "Reject follow request",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/follow_requests/#reject",
},
tags: ["Follows"],
responses: {
200: {
description:
"Your Relationship with this account should be unchanged.",
content: {
"application/json": {
schema: resolver(RelationshipSchema),
},
},
},
404: ApiError.accountNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({
auth: true,
permissions: [RolePermission.ManageOwnFollows],
}),
validator(
"param",
z.object({
account_id: AccountSchema.shape.id,
}),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const { account_id } = context.req.valid("param");
const account = await User.fromId(account_id);
if (!account) {
throw ApiError.accountNotFound();
}
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
account,
user,
);
await oppositeRelationship.update({
requested: false,
following: false,
});
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
account,
);
// Check if rejecting remote follow
if (account.isRemote()) {
// Federate follow reject
await user.sendFollowReject(account);
}
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -1,101 +1,112 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas"; import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/follow_requests", "/api/v1/follow_requests",
summary: "View pending follow requests", describeRoute({
description: "Get a list of follow requests that the user has received.", summary: "View pending follow requests",
externalDocs: { description:
url: "https://docs.joinmastodon.org/methods/follow_requests/#get", "Get a list of follow requests that the user has received.",
}, externalDocs: {
tags: ["Follows"], url: "https://docs.joinmastodon.org/methods/follow_requests/#get",
middleware: [ },
tags: ["Follows"],
responses: {
200: {
description:
"List of accounts that have requested to follow the user",
content: {
"application/json": {
schema: resolver(z.array(AccountSchema)),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({
description:
"Links to the next and previous pages",
example:
'<https://versia.social/api/v1/follow_requests?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/follow_requests?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
permissions: [RolePermission.ManageOwnFollows], permissions: [RolePermission.ManageOwnFollows],
}), }),
] as const, validator(
request: { "query",
query: z.object({ z.object({
max_id: AccountSchema.shape.id.optional().openapi({ max_id: AccountSchema.shape.id.optional().openapi({
description: description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.", "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}), }),
since_id: AccountSchema.shape.id.optional().openapi({ since_id: AccountSchema.shape.id.optional().openapi({
description: description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.", "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined, example: undefined,
}), }),
min_id: AccountSchema.shape.id.optional().openapi({ min_id: AccountSchema.shape.id.optional().openapi({
description: description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined, example: undefined,
}), }),
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ limit: z.coerce
description: "Maximum number of results to return.", .number()
}), .int()
}), .min(1)
}, .max(80)
responses: { .default(40)
200: {
description:
"List of accounts that have requested to follow the user",
content: {
"application/json": {
schema: z.array(AccountSchema),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({ .openapi({
description: "Links to the next and previous pages", description: "Maximum number of results to return.",
example:
'<https://versia.social/api/v1/follow_requests?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/follow_requests?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}), }),
}), }),
}, handleZodError,
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema, async (context) => {
}, const { max_id, since_id, min_id, limit } =
}); context.req.valid("query");
export default apiRoute((app) => const { user } = context.get("auth");
app.openapi(route, async (context) => {
const { max_id, since_id, min_id, limit } = context.req.valid("query");
const { user } = context.get("auth"); const { objects: followRequests, link } =
await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
),
limit,
new URL(context.req.url),
);
const { objects: followRequests, link } = return context.json(
await Timeline.getUserTimeline( followRequests.map((u) => u.toApi()),
and( 200,
max_id ? lt(Users.id, max_id) : undefined, {
since_id ? gte(Users.id, since_id) : undefined, Link: link,
min_id ? gt(Users.id, min_id) : undefined, },
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
),
limit,
new URL(context.req.url),
); );
},
return context.json( ),
followRequests.map((u) => u.toApi()),
200,
{
Link: link,
},
);
}),
); );

View file

@ -1,25 +1,29 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { z } from "zod";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/frontend/config", "/api/v1/frontend/config",
summary: "Get frontend config", describeRoute({
responses: { summary: "Get frontend config",
200: { responses: {
description: "Frontend config", 200: {
content: { description: "Frontend config",
"application/json": { content: {
schema: z.record(z.string(), z.any()).default({}), "application/json": {
schema: resolver(
z.record(z.string(), z.any()).default({}),
),
},
},
}, },
}, },
}),
(context) => {
return context.json(config.frontend.settings, 200);
}, },
}, ),
});
export default apiRoute((app) =>
app.openapi(route, (context) => {
return context.json(config.frontend.settings, 200);
}),
); );

View file

@ -1,46 +1,47 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { ExtendedDescription as ExtendedDescriptionSchema } from "@versia/client/schemas"; import { ExtendedDescription as ExtendedDescriptionSchema } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { markdownParse } from "~/classes/functions/status"; import { markdownParse } from "~/classes/functions/status";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/instance/extended_description", "/api/v1/instance/extended_description",
summary: "View extended description", describeRoute({
description: "Obtain an extended description of this server", summary: "View extended description",
externalDocs: { description: "Obtain an extended description of this server",
url: "https://docs.joinmastodon.org/methods/instance/#extended_description", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/instance/#extended_description",
tags: ["Instance"], },
responses: { tags: ["Instance"],
200: { responses: {
description: "Server extended description", 200: {
content: { description: "Server extended description",
"application/json": { content: {
schema: ExtendedDescriptionSchema, "application/json": {
schema: resolver(ExtendedDescriptionSchema),
},
},
}, },
}, },
}),
async (context) => {
const content = await markdownParse(
config.instance.extended_description_path?.content ??
"This is a [Versia](https://versia.pub) server with the default extended description.",
);
return context.json(
{
updated_at: new Date(
config.instance.extended_description_path?.file
.lastModified ?? 0,
).toISOString(),
content,
},
200,
);
}, },
}, ),
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const content = await markdownParse(
config.instance.extended_description_path?.content ??
"This is a [Versia](https://versia.pub) server with the default extended description.",
);
return context.json(
{
updated_at: new Date(
config.instance.extended_description_path?.file
.lastModified ?? 0,
).toISOString(),
content,
},
200,
);
}),
); );

View file

@ -1,147 +1,144 @@
import { apiRoute, auth } from "@/api"; import { apiRoute } from "@/api";
import { proxyUrl } from "@/response"; import { proxyUrl } from "@/response";
import { createRoute, type z } from "@hono/zod-openapi";
import { InstanceV1 as InstanceV1Schema } from "@versia/client/schemas"; import { InstanceV1 as InstanceV1Schema } from "@versia/client/schemas";
import { Instance, Note, User } from "@versia/kit/db"; import { Instance, Note, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import type { z } from "zod";
import { markdownParse } from "~/classes/functions/status"; import { markdownParse } from "~/classes/functions/status";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import manifest from "~/package.json"; import manifest from "~/package.json";
const route = createRoute({
method: "get",
path: "/api/v1/instance",
summary: "View server information (v1)",
description:
"Obtain general information about the server. See api/v2/instance instead.",
deprecated: true,
externalDocs: {
url: "https://docs.joinmastodon.org/methods/instance/#v1",
},
tags: ["Instance"],
middleware: [
auth({
auth: false,
}),
],
responses: {
200: {
description: "Instance information",
content: {
"application/json": {
schema: InstanceV1Schema,
},
},
},
},
});
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.get(
// Get software version from package.json "/api/v1/instance",
const version = manifest.version; describeRoute({
summary: "View server information (v1)",
const statusCount = await Note.getCount(); description:
"Obtain general information about the server. See api/v2/instance instead.",
const userCount = await User.getCount(); deprecated: true,
externalDocs: {
// Get first admin, or first user if no admin exists url: "https://docs.joinmastodon.org/methods/instance/#v1",
const contactAccount = },
(await User.fromSql( tags: ["Instance"],
and(isNull(Users.instanceId), eq(Users.isAdmin, true)), responses: {
)) ?? (await User.fromSql(isNull(Users.instanceId))); 200: {
description: "Instance information",
const knownDomainsCount = await Instance.getCount(); content: {
"application/json": {
const oidcConfig = config.plugins?.config?.["@versia/openid"] as schema: resolver(InstanceV1Schema),
| { },
forced?: boolean; },
providers?: {
id: string;
name: string;
icon: string;
}[];
}
| undefined;
const content = await markdownParse(
config.instance.extended_description_path?.content ??
"This is a [Versia](https://versia.pub) server with the default extended description.",
);
return context.json({
approval_required: config.registration.require_approval,
configuration: {
polls: {
max_characters_per_option:
config.validation.polls.max_option_characters,
max_expiration:
config.validation.polls.max_duration_seconds,
max_options: config.validation.polls.max_options,
min_expiration:
config.validation.polls.min_duration_seconds,
},
statuses: {
characters_reserved_per_url: 0,
max_characters: config.validation.notes.max_characters,
max_media_attachments:
config.validation.notes.max_attachments,
},
media_attachments: {
supported_mime_types:
config.validation.media.allowed_mime_types,
image_size_limit: config.validation.media.max_bytes,
// TODO: Implement
image_matrix_limit: 1 ** 10,
video_size_limit: 1 ** 10,
video_frame_rate_limit: 60,
video_matrix_limit: 1 ** 10,
},
accounts: {
max_featured_tags: 100,
}, },
}, },
short_description: config.instance.description, }),
description: content, async (context) => {
email: config.instance.contact.email, // Get software version from package.json
invites_enabled: false, const version = manifest.version;
registrations: config.registration.allow,
languages: config.instance.languages, const statusCount = await Note.getCount();
rules: config.instance.rules.map((r, index) => ({
id: String(index), const userCount = await User.getCount();
text: r.text,
hint: r.hint, // Get first admin, or first user if no admin exists
})), const contactAccount =
stats: { (await User.fromSql(
domain_count: knownDomainsCount, and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
status_count: statusCount, )) ?? (await User.fromSql(isNull(Users.instanceId)));
user_count: userCount,
}, const knownDomainsCount = await Instance.getCount();
thumbnail: config.instance.branding.logo
? proxyUrl(config.instance.branding.logo).toString() const oidcConfig = config.plugins?.config?.["@versia/openid"] as
: null, | {
title: config.instance.name, forced?: boolean;
uri: config.http.base_url.host, providers?: {
urls: { id: string;
// TODO: Implement Streaming API name: string;
streaming_api: "", icon: string;
}, }[];
version: "4.3.0-alpha.3+glitch", }
versia_version: version, | undefined;
// TODO: Put into plugin directly
sso: { const content = await markdownParse(
forced: oidcConfig?.forced ?? false, config.instance.extended_description_path?.content ??
providers: "This is a [Versia](https://versia.pub) server with the default extended description.",
oidcConfig?.providers?.map((p) => ({ );
name: p.name,
icon: p.icon return context.json({
? proxyUrl(new URL(p.icon)).toString() approval_required: config.registration.require_approval,
: undefined, configuration: {
id: p.id, polls: {
})) ?? [], max_characters_per_option:
}, config.validation.polls.max_option_characters,
contact_account: (contactAccount as User)?.toApi(), max_expiration:
} satisfies z.infer<typeof InstanceV1Schema>); config.validation.polls.max_duration_seconds,
}), max_options: config.validation.polls.max_options,
min_expiration:
config.validation.polls.min_duration_seconds,
},
statuses: {
characters_reserved_per_url: 0,
max_characters: config.validation.notes.max_characters,
max_media_attachments:
config.validation.notes.max_attachments,
},
media_attachments: {
supported_mime_types:
config.validation.media.allowed_mime_types,
image_size_limit: config.validation.media.max_bytes,
// TODO: Implement
image_matrix_limit: 1 ** 10,
video_size_limit: 1 ** 10,
video_frame_rate_limit: 60,
video_matrix_limit: 1 ** 10,
},
accounts: {
max_featured_tags: 100,
},
},
short_description: config.instance.description,
description: content,
email: config.instance.contact.email,
invites_enabled: false,
registrations: config.registration.allow,
languages: config.instance.languages,
rules: config.instance.rules.map((r, index) => ({
id: String(index),
text: r.text,
hint: r.hint,
})),
stats: {
domain_count: knownDomainsCount,
status_count: statusCount,
user_count: userCount,
},
thumbnail: config.instance.branding.logo
? proxyUrl(config.instance.branding.logo).toString()
: null,
title: config.instance.name,
uri: config.http.base_url.host,
urls: {
// TODO: Implement Streaming API
streaming_api: "",
},
version: "4.3.0-alpha.3+glitch",
versia_version: version,
// TODO: Put into plugin directly
sso: {
forced: oidcConfig?.forced ?? false,
providers:
oidcConfig?.providers?.map((p) => ({
name: p.name,
icon: p.icon
? proxyUrl(new URL(p.icon)).toString()
: undefined,
id: p.id,
})) ?? [],
},
contact_account: (contactAccount as User)?.toApi(),
} satisfies z.infer<typeof InstanceV1Schema>);
},
),
); );

View file

@ -1,47 +1,43 @@
import { apiRoute, auth } from "@/api"; import { apiRoute } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { PrivacyPolicy as PrivacyPolicySchema } from "@versia/client/schemas"; import { PrivacyPolicy as PrivacyPolicySchema } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { markdownParse } from "~/classes/functions/status"; import { markdownParse } from "~/classes/functions/status";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/instance/privacy_policy", "/api/v1/instance/privacy_policy",
summary: "View privacy policy", describeRoute({
description: "Obtain the contents of this servers privacy policy.", summary: "View privacy policy",
externalDocs: { description: "Obtain the contents of this servers privacy policy.",
url: "https://docs.joinmastodon.org/methods/instance/#privacy_policy", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/instance/#privacy_policy",
tags: ["Instance"], },
middleware: [ tags: ["Instance"],
auth({ responses: {
auth: false, 200: {
}), description: "Server privacy policy",
], content: {
responses: { "application/json": {
200: { schema: resolver(PrivacyPolicySchema),
description: "Server privacy policy", },
content: { },
"application/json": {
schema: PrivacyPolicySchema,
}, },
}, },
}),
async (context) => {
const content = await markdownParse(
config.instance.privacy_policy_path?.content ??
"This instance has not provided any privacy policy.",
);
return context.json({
updated_at: new Date(
config.instance.privacy_policy_path?.file.lastModified ?? 0,
).toISOString(),
content,
});
}, },
}, ),
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const content = await markdownParse(
config.instance.privacy_policy_path?.content ??
"This instance has not provided any privacy policy.",
);
return context.json({
updated_at: new Date(
config.instance.privacy_policy_path?.file.lastModified ?? 0,
).toISOString(),
content,
});
}),
); );

View file

@ -1,42 +1,39 @@
import { apiRoute, auth } from "@/api"; import { apiRoute } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Rule as RuleSchema } from "@versia/client/schemas"; import { Rule as RuleSchema } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { z } from "zod";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/instance/rules", "/api/v1/instance/rules",
summary: "List of rules", describeRoute({
description: "Rules that the users of this service should follow.", summary: "List of rules",
externalDocs: { description: "Rules that the users of this service should follow.",
url: "https://docs.joinmastodon.org/methods/instance/#rules", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/instance/#rules",
tags: ["Instance"], },
middleware: [ tags: ["Instance"],
auth({ responses: {
auth: false, 200: {
}), description: "Instance rules",
], content: {
responses: { "application/json": {
200: { schema: resolver(z.array(RuleSchema)),
description: "Instance rules", },
content: { },
"application/json": {
schema: z.array(RuleSchema),
}, },
}, },
}),
(context) => {
return context.json(
config.instance.rules.map((r, index) => ({
id: String(index),
text: r.text,
hint: r.hint,
})),
);
}, },
}, ),
});
export default apiRoute((app) =>
app.openapi(route, (context) => {
return context.json(
config.instance.rules.map((r, index) => ({
id: String(index),
text: r.text,
hint: r.hint,
})),
);
}),
); );

View file

@ -1,48 +1,44 @@
import { apiRoute, auth } from "@/api"; import { apiRoute } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { TermsOfService as TermsOfServiceSchema } from "@versia/client/schemas"; import { TermsOfService as TermsOfServiceSchema } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { markdownParse } from "~/classes/functions/status"; import { markdownParse } from "~/classes/functions/status";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/instance/terms_of_service", "/api/v1/instance/terms_of_service",
summary: "View terms of service", describeRoute({
description: summary: "View terms of service",
"Obtain the contents of this servers terms of service, if configured.", description:
externalDocs: { "Obtain the contents of this servers terms of service, if configured.",
url: "https://docs.joinmastodon.org/methods/instance/#terms_of_service", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/instance/#terms_of_service",
tags: ["Instance"], },
middleware: [ tags: ["Instance"],
auth({ responses: {
auth: false, 200: {
}), description: "Server terms of service",
], content: {
responses: { "application/json": {
200: { schema: resolver(TermsOfServiceSchema),
description: "Server terms of service", },
content: { },
"application/json": {
schema: TermsOfServiceSchema,
}, },
}, },
}),
async (context) => {
const content = await markdownParse(
config.instance.tos_path?.content ??
"This instance has not provided any terms of service.",
);
return context.json({
updated_at: new Date(
config.instance.tos_path?.file.lastModified ?? 0,
).toISOString(),
content,
});
}, },
}, ),
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const content = await markdownParse(
config.instance.tos_path?.content ??
"This instance has not provided any terms of service.",
);
return context.json({
updated_at: new Date(
config.instance.tos_path?.file.lastModified ?? 0,
).toISOString(),
content,
});
}),
); );

View file

@ -1,5 +1,4 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { import {
Marker as MarkerSchema, Marker as MarkerSchema,
Notification as NotificationSchema, Notification as NotificationSchema,
@ -9,6 +8,9 @@ import { RolePermission } from "@versia/client/schemas";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { Markers } from "@versia/kit/tables"; import { Markers } from "@versia/kit/tables";
import { type SQL, and, eq } from "drizzle-orm"; import { type SQL, and, eq } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const MarkerResponseSchema = z.object({ const MarkerResponseSchema = z.object({
@ -16,221 +18,231 @@ const MarkerResponseSchema = z.object({
home: MarkerSchema.optional(), home: MarkerSchema.optional(),
}); });
const routeGet = createRoute({
method: "get",
path: "/api/v1/markers",
summary: "Get saved timeline positions",
description: "Get current positions in timelines.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/markers/#get",
},
tags: ["Timelines"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.ManageOwnAccount],
}),
] as const,
request: {
query: z.object({
"timeline[]": z
.array(z.enum(["home", "notifications"]))
.max(2)
.or(z.enum(["home", "notifications"]).transform((t) => [t]))
.optional()
.openapi({
description:
"Specify the timeline(s) for which markers should be fetched. Possible values: home, notifications. If not provided, an empty object will be returned.",
}),
}),
},
responses: {
200: {
description: "Markers",
content: {
"application/json": {
schema: MarkerResponseSchema,
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
const routePost = createRoute({
method: "post",
path: "/api/v1/markers",
summary: "Save your position in a timeline",
description: "Save current position in timeline.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/markers/#create",
},
tags: ["Timelines"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.ManageOwnAccount],
}),
] as const,
request: {
query: z
.object({
"home[last_read_id]": StatusSchema.shape.id.openapi({
description:
"ID of the last status read in the home timeline.",
example: "c62aa212-8198-4ce5-a388-2cc8344a84ef",
}),
"notifications[last_read_id]":
NotificationSchema.shape.id.openapi({
description: "ID of the last notification read.",
}),
})
.partial(),
},
responses: {
200: {
description: "Markers",
content: {
"application/json": {
schema: MarkerResponseSchema,
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) => { export default apiRoute((app) => {
app.openapi(routeGet, async (context) => { app.get(
const { "timeline[]": timeline } = context.req.valid("query"); "/api/v1/markers",
const { user } = context.get("auth"); describeRoute({
summary: "Get saved timeline positions",
description: "Get current positions in timelines.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/markers/#get",
},
tags: ["Timelines"],
responses: {
200: {
description: "Markers",
content: {
"application/json": {
schema: resolver(MarkerResponseSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({
auth: true,
permissions: [RolePermission.ManageOwnAccount],
}),
validator(
"query",
z.object({
"timeline[]": z
.array(z.enum(["home", "notifications"]))
.max(2)
.or(z.enum(["home", "notifications"]).transform((t) => [t]))
.optional()
.openapi({
description:
"Specify the timeline(s) for which markers should be fetched. Possible values: home, notifications. If not provided, an empty object will be returned.",
}),
}),
handleZodError,
),
async (context) => {
const { "timeline[]": timeline } = context.req.valid("query");
const { user } = context.get("auth");
if (!timeline) { if (!timeline) {
return context.json({}, 200); return context.json({}, 200);
} }
const markers: z.infer<typeof MarkerResponseSchema> = { const markers: z.infer<typeof MarkerResponseSchema> = {
home: undefined, home: undefined,
notifications: undefined, notifications: undefined,
}; };
if (timeline.includes("home")) { if (timeline.includes("home")) {
const found = await db.query.Markers.findFirst({ const found = await db.query.Markers.findFirst({
where: (marker, { and, eq }): SQL | undefined => where: (marker, { and, eq }): SQL | undefined =>
and(
eq(marker.userId, user.id),
eq(marker.timeline, "home"),
),
});
const totalCount = await db.$count(
Markers,
and( and(
eq(marker.userId, user.id), eq(Markers.userId, user.id),
eq(marker.timeline, "home"), eq(Markers.timeline, "home"),
), ),
}); );
const totalCount = await db.$count( if (found?.noteId) {
Markers, markers.home = {
and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")), last_read_id: found.noteId,
); version: totalCount,
updated_at: new Date(found.createdAt).toISOString(),
};
}
}
if (timeline.includes("notifications")) {
const found = await db.query.Markers.findFirst({
where: (marker, { and, eq }): SQL | undefined =>
and(
eq(marker.userId, user.id),
eq(marker.timeline, "notifications"),
),
});
const totalCount = await db.$count(
Markers,
and(
eq(Markers.userId, user.id),
eq(Markers.timeline, "notifications"),
),
);
if (found?.notificationId) {
markers.notifications = {
last_read_id: found.notificationId,
version: totalCount,
updated_at: new Date(found.createdAt).toISOString(),
};
}
}
return context.json(markers, 200);
},
);
app.post(
"/api/v1/markers",
describeRoute({
summary: "Save your position in a timeline",
description: "Save current position in timeline.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/markers/#create",
},
tags: ["Timelines"],
responses: {
200: {
description: "Markers",
content: {
"application/json": {
schema: resolver(MarkerResponseSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({
auth: true,
permissions: [RolePermission.ManageOwnAccount],
}),
validator(
"query",
z
.object({
"home[last_read_id]": StatusSchema.shape.id.openapi({
description:
"ID of the last status read in the home timeline.",
example: "c62aa212-8198-4ce5-a388-2cc8344a84ef",
}),
"notifications[last_read_id]":
NotificationSchema.shape.id.openapi({
description: "ID of the last notification read.",
}),
})
.partial(),
handleZodError,
),
async (context) => {
const {
"home[last_read_id]": homeId,
"notifications[last_read_id]": notificationsId,
} = context.req.valid("query");
const { user } = context.get("auth");
const markers: z.infer<typeof MarkerResponseSchema> = {
home: undefined,
notifications: undefined,
};
if (homeId) {
const insertedMarker = (
await db
.insert(Markers)
.values({
userId: user.id,
timeline: "home",
noteId: homeId,
})
.returning()
)[0];
const totalCount = await db.$count(
Markers,
and(
eq(Markers.userId, user.id),
eq(Markers.timeline, "home"),
),
);
if (found?.noteId) {
markers.home = { markers.home = {
last_read_id: found.noteId, last_read_id: homeId,
version: totalCount, version: totalCount,
updated_at: new Date(found.createdAt).toISOString(), updated_at: new Date(
insertedMarker.createdAt,
).toISOString(),
}; };
} }
}
if (timeline.includes("notifications")) { if (notificationsId) {
const found = await db.query.Markers.findFirst({ const insertedMarker = (
where: (marker, { and, eq }): SQL | undefined => await db
.insert(Markers)
.values({
userId: user.id,
timeline: "notifications",
notificationId: notificationsId,
})
.returning()
)[0];
const totalCount = await db.$count(
Markers,
and( and(
eq(marker.userId, user.id), eq(Markers.userId, user.id),
eq(marker.timeline, "notifications"), eq(Markers.timeline, "notifications"),
), ),
}); );
const totalCount = await db.$count(
Markers,
and(
eq(Markers.userId, user.id),
eq(Markers.timeline, "notifications"),
),
);
if (found?.notificationId) {
markers.notifications = { markers.notifications = {
last_read_id: found.notificationId, last_read_id: notificationsId,
version: totalCount, version: totalCount,
updated_at: new Date(found.createdAt).toISOString(), updated_at: new Date(
insertedMarker.createdAt,
).toISOString(),
}; };
} }
}
return context.json(markers, 200); return context.json(markers, 200);
}); },
);
app.openapi(routePost, async (context) => {
const {
"home[last_read_id]": homeId,
"notifications[last_read_id]": notificationsId,
} = context.req.valid("query");
const { user } = context.get("auth");
const markers: z.infer<typeof MarkerResponseSchema> = {
home: undefined,
notifications: undefined,
};
if (homeId) {
const insertedMarker = (
await db
.insert(Markers)
.values({
userId: user.id,
timeline: "home",
noteId: homeId,
})
.returning()
)[0];
const totalCount = await db.$count(
Markers,
and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")),
);
markers.home = {
last_read_id: homeId,
version: totalCount,
updated_at: new Date(insertedMarker.createdAt).toISOString(),
};
}
if (notificationsId) {
const insertedMarker = (
await db
.insert(Markers)
.values({
userId: user.id,
timeline: "notifications",
notificationId: notificationsId,
})
.returning()
)[0];
const totalCount = await db.$count(
Markers,
and(
eq(Markers.userId, user.id),
eq(Markers.timeline, "notifications"),
),
);
markers.notifications = {
last_read_id: notificationsId,
version: totalCount,
updated_at: new Date(insertedMarker.createdAt).toISOString(),
};
}
return context.json(markers, 200);
});
}); });

View file

@ -1,154 +0,0 @@
import { apiRoute, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Attachment as AttachmentSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const routePut = createRoute({
method: "put",
path: "/api/v1/media/{id}",
summary: "Update media attachment",
description:
"Update a MediaAttachments parameters, before it is attached to a status and posted.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/media/#update",
},
tags: ["Media"],
middleware: [
auth({
auth: true,
scopes: ["write:media"],
permissions: [RolePermission.ManageOwnMedia],
}),
] as const,
request: {
params: z.object({
id: AttachmentSchema.shape.id,
}),
body: {
content: {
"multipart/form-data": {
schema: z
.object({
thumbnail: z.instanceof(File).openapi({
description:
"The custom thumbnail of the media to be attached, encoded using multipart form data.",
}),
description: AttachmentSchema.shape.description,
focus: z.string().openapi({
description:
"Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
},
}),
})
.partial(),
},
},
},
},
responses: {
200: {
description: "Updated attachment",
content: {
"application/json": {
schema: AttachmentSchema,
},
},
},
404: {
description: "Attachment not found",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
const routeGet = createRoute({
method: "get",
path: "/api/v1/media/{id}",
summary: "Get media attachment",
description:
"Get a media attachment, before it is attached to a status and posted, but after it is accepted for processing. Use this method to check that the full-sized media has finished processing.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/media/#get",
},
tags: ["Media"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.ManageOwnMedia],
}),
] as const,
request: {
params: z.object({
id: AttachmentSchema.shape.id,
}),
},
responses: {
200: {
description: "Attachment",
content: {
"application/json": {
schema: AttachmentSchema,
},
},
},
404: {
description: "Attachment not found",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) => {
app.openapi(routePut, async (context) => {
const { id } = context.req.valid("param");
const media = await Media.fromId(id);
if (!media) {
throw ApiError.mediaNotFound();
}
const { description, thumbnail: thumbnailFile } =
context.req.valid("form");
if (thumbnailFile) {
await media.updateThumbnail(thumbnailFile);
}
if (description) {
await media.updateMetadata({
description,
});
}
return context.json(media.toApi(), 200);
});
app.openapi(routeGet, async (context) => {
const { id } = context.req.valid("param");
const attachment = await Media.fromId(id);
if (!attachment) {
throw ApiError.mediaNotFound();
}
return context.json(attachment.toApi(), 200);
});
});

View file

@ -0,0 +1,154 @@
import { apiRoute, auth, handleZodError } from "@/api";
import { Attachment as AttachmentSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => {
app.get(
"/api/v1/media/:id",
describeRoute({
summary: "Get media attachment",
description:
"Get a media attachment, before it is attached to a status and posted, but after it is accepted for processing. Use this method to check that the full-sized media has finished processing.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/media/#get",
},
tags: ["Media"],
responses: {
200: {
description: "Attachment",
content: {
"application/json": {
schema: resolver(AttachmentSchema),
},
},
},
404: {
description: "Attachment not found",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({
auth: true,
permissions: [RolePermission.ManageOwnMedia],
}),
validator(
"param",
z.object({
id: AttachmentSchema.shape.id,
}),
handleZodError,
),
async (context) => {
const { id } = context.req.valid("param");
const attachment = await Media.fromId(id);
if (!attachment) {
throw ApiError.mediaNotFound();
}
return context.json(attachment.toApi(), 200);
},
);
app.put(
"/api/v1/media/:id",
describeRoute({
summary: "Update media attachment",
description:
"Update a MediaAttachments parameters, before it is attached to a status and posted.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/media/#update",
},
tags: ["Media"],
responses: {
200: {
description: "Updated attachment",
content: {
"application/json": {
schema: resolver(AttachmentSchema),
},
},
},
404: {
description: "Attachment not found",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({
auth: true,
scopes: ["write:media"],
permissions: [RolePermission.ManageOwnMedia],
}),
validator(
"form",
z
.object({
thumbnail: z.instanceof(File).openapi({
description:
"The custom thumbnail of the media to be attached, encoded using multipart form data.",
}),
description: AttachmentSchema.shape.description,
focus: z.string().openapi({
description:
"Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
},
}),
})
.partial(),
handleZodError,
),
validator(
"param",
z.object({
id: AttachmentSchema.shape.id,
}),
handleZodError,
),
async (context) => {
const { id } = context.req.valid("param");
const media = await Media.fromId(id);
if (!media) {
throw ApiError.mediaNotFound();
}
const { description, thumbnail: thumbnailFile } =
context.req.valid("form");
if (thumbnailFile) {
await media.updateThumbnail(thumbnailFile);
}
if (description) {
await media.updateMetadata({
description,
});
}
return context.json(media.toApi(), 200);
},
);
});

View file

@ -1,98 +1,93 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Attachment as AttachmentSchema } from "@versia/client/schemas"; import { Attachment as AttachmentSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Media } from "@versia/kit/db"; import { Media } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "post", app.post(
path: "/api/v1/media", "/api/v1/media",
summary: "Upload media as an attachment (v1)", describeRoute({
description: summary: "Upload media as an attachment (v1)",
"Creates an attachment to be used with a new status. This method will return after the full sized media is done processing.", description:
deprecated: true, "Creates an attachment to be used with a new status. This method will return after the full sized media is done processing.",
externalDocs: { deprecated: true,
url: "https://docs.joinmastodon.org/methods/media/#v1", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/media/#v1",
tags: ["Media"], },
middleware: [ tags: ["Media"],
responses: {
200: {
description:
"Attachment created successfully. Note that the MediaAttachment will be created even if the file is not understood correctly due to failed processing.",
content: {
"application/json": {
schema: resolver(AttachmentSchema),
},
},
},
413: {
description: "File too large",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
415: {
description: "Disallowed file type",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
scopes: ["write:media"], scopes: ["write:media"],
permissions: [RolePermission.ManageOwnMedia], permissions: [RolePermission.ManageOwnMedia],
}), }),
] as const, validator(
request: { "form",
body: { z.object({
content: { file: z.instanceof(File).openapi({
"multipart/form-data": { description:
schema: z.object({ "The file to be attached, encoded using multipart form data. The file must have a MIME type.",
file: z.instanceof(File).openapi({ }),
description: thumbnail: z.instanceof(File).optional().openapi({
"The file to be attached, encoded using multipart form data. The file must have a MIME type.", description:
}), "The custom thumbnail of the media to be attached, encoded using multipart form data.",
thumbnail: z.instanceof(File).optional().openapi({ }),
description: description: AttachmentSchema.shape.description.optional(),
"The custom thumbnail of the media to be attached, encoded using multipart form data.", focus: z
}), .string()
.optional()
.openapi({
description: description:
AttachmentSchema.shape.description.optional(), "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
focus: z externalDocs: {
.string() url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
.optional() },
.openapi({
description:
"Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
},
}),
}), }),
}, }),
}, handleZodError,
}, ),
}, async (context) => {
responses: { const { file, thumbnail, description } = context.req.valid("form");
200: {
description:
"Attachment created successfully. Note that the MediaAttachment will be created even if the file is not understood correctly due to failed processing.",
content: {
"application/json": {
schema: AttachmentSchema,
},
},
},
413: {
description: "File too large",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
415: {
description: "Disallowed file type",
content: {
"application/json": {
schema: ApiError.zodSchema,
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) => const attachment = await Media.fromFile(file, {
app.openapi(route, async (context) => { thumbnail,
const { file, thumbnail, description } = context.req.valid("form"); description: description ?? undefined,
});
const attachment = await Media.fromFile(file, { return context.json(attachment.toApi(), 200);
thumbnail, },
description: description ?? undefined, ),
});
return context.json(attachment.toApi(), 200);
}),
); );

View file

@ -1,99 +1,109 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas"; import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/mutes", "/api/v1/mutes",
summary: "View muted accounts", describeRoute({
description: "View your mutes.", summary: "View muted accounts",
externalDocs: { description: "View your mutes.",
url: "https://docs.joinmastodon.org/methods/mutes/#get", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/mutes/#get",
tags: ["Mutes"], },
middleware: [ tags: ["Mutes"],
responses: {
200: {
description: "List of muted users",
content: {
"application/json": {
schema: resolver(z.array(AccountSchema)),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({
description:
"Links to the next and previous pages",
example:
'<https://versia.social/api/v1/mutes?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/mutes?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}),
}),
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
scopes: ["read:mutes"], scopes: ["read:mutes"],
permissions: [RolePermission.ManageOwnMutes], permissions: [RolePermission.ManageOwnMutes],
}), }),
] as const, validator(
request: { "query",
query: z.object({ z.object({
max_id: AccountSchema.shape.id.optional().openapi({ max_id: AccountSchema.shape.id.optional().openapi({
description: description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.", "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}), }),
since_id: AccountSchema.shape.id.optional().openapi({ since_id: AccountSchema.shape.id.optional().openapi({
description: description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.", "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined, example: undefined,
}), }),
min_id: AccountSchema.shape.id.optional().openapi({ min_id: AccountSchema.shape.id.optional().openapi({
description: description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined, example: undefined,
}), }),
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({ limit: z.coerce
description: "Maximum number of results to return.", .number()
}), .int()
}), .min(1)
}, .max(80)
responses: { .default(40)
200: {
description: "List of muted users",
content: {
"application/json": {
schema: z.array(AccountSchema),
},
},
headers: z.object({
link: z
.string()
.optional()
.openapi({ .openapi({
description: "Links to the next and previous pages", description: "Maximum number of results to return.",
example:
'<https://versia.social/api/v1/mutes?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/mutes?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
externalDocs: {
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
},
}), }),
}), }),
handleZodError,
),
async (context) => {
const { max_id, since_id, limit, min_id } =
context.req.valid("query");
const { user } = context.get("auth");
const { objects: mutes, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
),
limit,
new URL(context.req.url),
);
return context.json(
mutes.map((u) => u.toApi()),
200,
{
Link: link,
},
);
}, },
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { max_id, since_id, limit, min_id } = context.req.valid("query");
const { user } = context.get("auth");
const { objects: mutes, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
),
limit,
new URL(context.req.url),
);
return context.json(
mutes.map((u) => u.toApi()),
200,
{
Link: link,
},
);
}),
); );

View file

@ -1,56 +0,0 @@
import { apiRoute, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Notification as NotificationSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Notification } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "post",
path: "/api/v1/notifications/{id}/dismiss",
summary: "Dismiss a single notification",
description: "Dismiss a single notification from the server.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/notifications/#dismiss",
},
tags: ["Notifications"],
middleware: [
auth({
auth: true,
scopes: ["write:notifications"],
permissions: [RolePermission.ManageOwnNotifications],
}),
] as const,
request: {
params: z.object({
id: NotificationSchema.shape.id,
}),
},
responses: {
200: {
description: "Notification with given ID successfully dismissed",
},
401: ApiError.missingAuthentication().schema,
404: ApiError.notificationNotFound().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const notification = await Notification.fromId(id);
if (!notification || notification.data.notifiedId !== user.id) {
throw ApiError.notificationNotFound();
}
await notification.update({
dismissed: true,
});
return context.text("", 200);
}),
);

View file

@ -1,57 +0,0 @@
import { apiRoute, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Notification as NotificationSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Notification } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({
method: "get",
path: "/api/v1/notifications/{id}",
summary: "Get a single notification",
description: "View information about a notification with a given ID.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/notifications/#get",
},
tags: ["Notifications"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.ManageOwnNotifications],
scopes: ["read:notifications"],
}),
] as const,
request: {
params: z.object({
id: NotificationSchema.shape.id,
}),
},
responses: {
200: {
description: "A single Notification",
content: {
"application/json": {
schema: NotificationSchema,
},
},
},
404: ApiError.notificationNotFound().schema,
401: ApiError.missingAuthentication().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const notification = await Notification.fromId(id, user.id);
if (!notification || notification.data.notifiedId !== user.id) {
throw ApiError.notificationNotFound();
}
return context.json(await notification.toApi(), 200);
}),
);

View file

@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { z } from "@hono/zod-openapi";
import type { Notification } from "@versia/client/schemas"; import type { Notification } from "@versia/client/schemas";
import type { z } from "zod";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2); const { users, deleteUsers } = await getTestUsers(2);

View file

@ -0,0 +1,59 @@
import { apiRoute, auth, handleZodError } from "@/api";
import { Notification as NotificationSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Notification } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.post(
"/api/v1/notifications/:id/dismiss",
describeRoute({
summary: "Dismiss a single notification",
description: "Dismiss a single notification from the server.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/notifications/#dismiss",
},
tags: ["Notifications"],
responses: {
200: {
description:
"Notification with given ID successfully dismissed",
},
401: ApiError.missingAuthentication().schema,
404: ApiError.notificationNotFound().schema,
},
}),
auth({
auth: true,
scopes: ["write:notifications"],
permissions: [RolePermission.ManageOwnNotifications],
}),
validator(
"param",
z.object({
id: NotificationSchema.shape.id,
}),
handleZodError,
),
async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const notification = await Notification.fromId(id);
if (!notification || notification.data.notifiedId !== user.id) {
throw ApiError.notificationNotFound();
}
await notification.update({
dismissed: true,
});
return context.text("", 200);
},
),
);

View file

@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { z } from "@hono/zod-openapi";
import type { Notification } from "@versia/client/schemas"; import type { Notification } from "@versia/client/schemas";
import type { z } from "zod";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2); const { users, deleteUsers } = await getTestUsers(2);

View file

@ -0,0 +1,60 @@
import { apiRoute, auth, handleZodError } from "@/api";
import { Notification as NotificationSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Notification } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.get(
"/api/v1/notifications/:id",
describeRoute({
summary: "Get a single notification",
description:
"View information about a notification with a given ID.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/notifications/#get",
},
tags: ["Notifications"],
responses: {
200: {
description: "A single Notification",
content: {
"application/json": {
schema: resolver(NotificationSchema),
},
},
},
404: ApiError.notificationNotFound().schema,
401: ApiError.missingAuthentication().schema,
},
}),
auth({
auth: true,
permissions: [RolePermission.ManageOwnNotifications],
scopes: ["read:notifications"],
}),
validator(
"param",
z.object({
id: NotificationSchema.shape.id,
}),
handleZodError,
),
async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const notification = await Notification.fromId(id, user.id);
if (!notification || notification.data.notifiedId !== user.id) {
throw ApiError.notificationNotFound();
}
return context.json(await notification.toApi(), 200);
},
),
);

View file

@ -1,38 +1,36 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "post", app.post(
path: "/api/v1/notifications/clear", "/api/v1/notifications/clear",
summary: "Dismiss all notifications", describeRoute({
description: "Clear all notifications from the server.", summary: "Dismiss all notifications",
externalDocs: { description: "Clear all notifications from the server.",
url: "https://docs.joinmastodon.org/methods/notifications/#clear", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/notifications/#clear",
tags: ["Notifications"], },
middleware: [ tags: ["Notifications"],
responses: {
200: {
description: "Notifications successfully cleared.",
},
401: ApiError.missingAuthentication().schema,
},
}),
auth({ auth({
auth: true, auth: true,
permissions: [RolePermission.ManageOwnNotifications], permissions: [RolePermission.ManageOwnNotifications],
scopes: ["write:notifications"], scopes: ["write:notifications"],
}), }),
] as const, async (context) => {
responses: { const { user } = context.get("auth");
200: {
description: "Notifications successfully cleared.", await user.clearAllNotifications();
return context.text("", 200);
}, },
401: ApiError.missingAuthentication().schema, ),
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
await user.clearAllNotifications();
return context.text("", 200);
}),
); );

View file

@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { z } from "@hono/zod-openapi";
import type { Notification } from "@versia/client/schemas"; import type { Notification } from "@versia/client/schemas";
import type { z } from "zod";
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils"; import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2); const { users, deleteUsers } = await getTestUsers(2);

View file

@ -1,45 +1,44 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const schemas = { export default apiRoute((app) =>
query: z.object({ app.delete(
"ids[]": z.array(z.string().uuid()), "/api/v1/notifications/destroy_multiple",
}), describeRoute({
}; summary: "Dismiss multiple notifications",
tags: ["Notifications"],
const route = createRoute({ responses: {
method: "delete", 200: {
path: "/api/v1/notifications/destroy_multiple", description: "Notifications dismissed",
summary: "Dismiss multiple notifications", },
tags: ["Notifications"], 401: ApiError.missingAuthentication().schema,
middleware: [ },
}),
auth({ auth({
auth: true, auth: true,
permissions: [RolePermission.ManageOwnNotifications], permissions: [RolePermission.ManageOwnNotifications],
scopes: ["write:notifications"], scopes: ["write:notifications"],
}), }),
] as const, qsQuery(),
request: { validator(
query: schemas.query, "query",
}, z.object({
responses: { ids: z.array(z.string().uuid()),
200: { }),
description: "Notifications dismissed", handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const { ids } = context.req.valid("query");
await user.clearSomeNotifications(ids);
return context.text("", 200);
}, },
401: ApiError.missingAuthentication().schema, ),
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const { "ids[]": ids } = context.req.valid("query");
await user.clearSomeNotifications(ids);
return context.text("", 200);
}),
); );

View file

@ -1,5 +1,4 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, handleZodError } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { import {
Account as AccountSchema, Account as AccountSchema,
Notification as NotificationSchema, Notification as NotificationSchema,
@ -9,18 +8,34 @@ import { RolePermission } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { Notifications } from "@versia/kit/tables"; import { Notifications } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm"; import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "get", app.get(
path: "/api/v1/notifications", "/api/v1/notifications",
summary: "Get all notifications", describeRoute({
description: "Notifications concerning the user.", summary: "Get all notifications",
externalDocs: { description: "Notifications concerning the user.",
url: "https://docs.joinmastodon.org/methods/notifications/#get", externalDocs: {
}, url: "https://docs.joinmastodon.org/methods/notifications/#get",
tags: ["Notifications"], },
middleware: [ tags: ["Notifications"],
responses: {
200: {
description: "Notifications",
content: {
"application/json": {
schema: resolver(z.array(NotificationSchema)),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
permissions: [ permissions: [
@ -28,135 +43,122 @@ const route = createRoute({
RolePermission.ViewPrivateTimelines, RolePermission.ViewPrivateTimelines,
], ],
}), }),
] as const, validator(
request: { "query",
query: z z
.object({ .object({
max_id: NotificationSchema.shape.id.optional().openapi({ max_id: NotificationSchema.shape.id.optional().openapi({
description: description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.", "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: NotificationSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: NotificationSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.coerce
.number()
.int()
.min(1)
.max(80)
.default(40)
.openapi({
description: "Maximum number of results to return.",
}), }),
types: z since_id: NotificationSchema.shape.id.optional().openapi({
.array(NotificationSchema.shape.type) description:
.optional() "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
.openapi({ example: undefined,
description: "Types to include in the result.",
}), }),
exclude_types: z min_id: NotificationSchema.shape.id.optional().openapi({
.array(NotificationSchema.shape.type) description:
.optional() "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
.openapi({ example: undefined,
description: "Types to exclude from the results.",
}), }),
account_id: AccountSchema.shape.id.optional().openapi({ limit: z.coerce
description: .number()
"Return only notifications received from the specified account.", .int()
}), .min(1)
// TODO: Implement .max(80)
include_filtered: zBoolean.default(false).openapi({ .default(40)
description: .openapi({
"Whether to include notifications filtered by the users NotificationPolicy.", description: "Maximum number of results to return.",
}), }),
}) types: z
.refine((val) => { .array(NotificationSchema.shape.type)
// Can't use both exclude_types and types .optional()
return !(val.exclude_types && val.types); .openapi({
}, "Can't use both exclude_types and types"), description: "Types to include in the result.",
}, }),
responses: { exclude_types: z
200: { .array(NotificationSchema.shape.type)
description: "Notifications", .optional()
content: { .openapi({
"application/json": { description: "Types to exclude from the results.",
schema: z.array(NotificationSchema), }),
}, account_id: AccountSchema.shape.id.optional().openapi({
}, description:
}, "Return only notifications received from the specified account.",
401: ApiError.missingAuthentication().schema, }),
422: ApiError.validationFailed().schema, // TODO: Implement
}, include_filtered: zBoolean.default(false).openapi({
}); description:
"Whether to include notifications filtered by the user's NotificationPolicy.",
}),
})
.refine((val) => {
// Can't use both exclude_types and types
return !(val.exclude_types && val.types);
}, "Can't use both exclude_types and types"),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
export default apiRoute((app) => const {
app.openapi(route, async (context) => { account_id,
const { user } = context.get("auth"); exclude_types,
limit,
max_id,
min_id,
since_id,
types,
} = context.req.valid("query");
const { const { objects, link } = await Timeline.getNotificationTimeline(
account_id, and(
exclude_types, max_id ? lt(Notifications.id, max_id) : undefined,
limit, since_id ? gte(Notifications.id, since_id) : undefined,
max_id, min_id ? gt(Notifications.id, min_id) : undefined,
min_id, eq(Notifications.notifiedId, user.id),
since_id, eq(Notifications.dismissed, false),
types, account_id
} = context.req.valid("query"); ? eq(Notifications.accountId, account_id)
: undefined,
const { objects, link } = await Timeline.getNotificationTimeline( not(eq(Notifications.accountId, user.id)),
and( types ? inArray(Notifications.type, types) : undefined,
max_id ? lt(Notifications.id, max_id) : undefined, exclude_types
since_id ? gte(Notifications.id, since_id) : undefined, ? not(inArray(Notifications.type, exclude_types))
min_id ? gt(Notifications.id, min_id) : undefined, : undefined,
eq(Notifications.notifiedId, user.id), // Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
eq(Notifications.dismissed, false), // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
account_id // Filters table has a userId and a context which is an array
? eq(Notifications.accountId, account_id) sql`NOT EXISTS (
: undefined,
not(eq(Notifications.accountId, user.id)),
types ? inArray(Notifications.type, types) : undefined,
exclude_types
? not(inArray(Notifications.type, exclude_types))
: undefined,
// Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
// Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
// Filters table has a userId and a context which is an array
sql`NOT EXISTS (
SELECT 1
FROM "Filters"
WHERE "Filters"."userId" = ${user.id}
AND "Filters"."filter_action" = 'hide'
AND EXISTS (
SELECT 1 SELECT 1
FROM "FilterKeywords", "Notifications" as "n_inner", "Notes" FROM "Filters"
WHERE "FilterKeywords"."filterId" = "Filters"."id" WHERE "Filters"."userId" = ${user.id}
AND "n_inner"."noteId" = "Notes"."id" AND "Filters"."filter_action" = 'hide'
AND "Notes"."content" LIKE AND EXISTS (
'%' || "FilterKeywords"."keyword" || '%' SELECT 1
AND "n_inner"."id" = "Notifications"."id" FROM "FilterKeywords", "Notifications" as "n_inner", "Notes"
) WHERE "FilterKeywords"."filterId" = "Filters"."id"
AND "Filters"."context" @> ARRAY['notifications'] AND "n_inner"."noteId" = "Notes"."id"
)`, AND "Notes"."content" LIKE
), '%' || "FilterKeywords"."keyword" || '%'
limit, AND "n_inner"."id" = "Notifications"."id"
new URL(context.req.url), )
user.id, AND "Filters"."context" @> ARRAY['notifications']
); )`,
),
limit,
new URL(context.req.url),
user.id,
);
return context.json( return context.json(
await Promise.all(objects.map((n) => n.toApi())), await Promise.all(objects.map((n) => n.toApi())),
200, 200,
{ {
Link: link, Link: link,
}, },
); );
}), },
),
); );

View file

@ -1,48 +1,48 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Account } from "@versia/client/schemas"; import { Account } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "delete", app.delete(
path: "/api/v1/profile/avatar", "/api/v1/profile/avatar",
summary: "Delete profile avatar", describeRoute({
description: "Deletes the avatar associated with the users profile.", summary: "Delete profile avatar",
externalDocs: { description:
url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar", "Deletes the avatar associated with the users profile.",
}, externalDocs: {
tags: ["Profile"], url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar",
middleware: [ },
tags: ["Profile"],
responses: {
200: {
description:
"The avatar was successfully deleted from the users profile. If there were no avatar associated with the profile, the response will still indicate a successful deletion.",
content: {
"application/json": {
// TODO: Return a CredentialAccount
schema: resolver(Account),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
permissions: [RolePermission.ManageOwnAccount], permissions: [RolePermission.ManageOwnAccount],
scopes: ["write:account"], scopes: ["write:account"],
}), }),
] as const, async (context) => {
responses: { const { user } = context.get("auth");
200: {
description: await user.avatar?.delete();
"The avatar was successfully deleted from the users profile. If there were no avatar associated with the profile, the response will still indicate a successful deletion.", await user.reload();
content: {
"application/json": { return context.json(user.toApi(true), 200);
// TODO: Return a CredentialAccount
schema: Account,
},
},
}, },
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
await user.avatar?.delete();
await user.reload();
return context.json(user.toApi(true), 200);
}),
); );

View file

@ -1,46 +1,46 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Account } from "@versia/client/schemas"; import { Account } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
const route = createRoute({ export default apiRoute((app) =>
method: "delete", app.delete(
path: "/api/v1/profile/header", "/api/v1/profile/header",
summary: "Delete profile header", describeRoute({
description: "Deletes the header image associated with the users profile.", summary: "Delete profile header",
externalDocs: { description:
url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-header", "Deletes the header image associated with the users profile.",
}, externalDocs: {
tags: ["Profiles"], url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-header",
middleware: [ },
tags: ["Profiles"],
responses: {
200: {
description:
"The header was successfully deleted from the users profile. If there were no header associated with the profile, the response will still indicate a successful deletion.",
content: {
"application/json": {
schema: resolver(Account),
},
},
},
401: ApiError.missingAuthentication().schema,
422: ApiError.validationFailed().schema,
},
}),
auth({ auth({
auth: true, auth: true,
permissions: [RolePermission.ManageOwnAccount], permissions: [RolePermission.ManageOwnAccount],
scopes: ["write:account"], scopes: ["write:account"],
}), }),
] as const, async (context) => {
responses: { const { user } = context.get("auth");
200: {
description: await user.header?.delete();
"The header was successfully deleted from the users profile. If there were no header associated with the profile, the response will still indicate a successful deletion.", await user.reload();
content: { return context.json(user.toApi(true), 200);
"application/json": {
schema: Account,
},
},
}, },
401: ApiError.missingAuthentication().schema, ),
422: ApiError.validationFailed().schema,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
await user.header?.delete();
await user.reload();
return context.json(user.toApi(true), 200);
}),
); );

View file

@ -1,34 +1,28 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { PushSubscription } from "@versia/kit/db"; import { PushSubscription } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi( app.delete(
createRoute({ "/api/v1/push/subscription",
method: "delete", describeRoute({
path: "/api/v1/push/subscription",
summary: "Remove current subscription", summary: "Remove current subscription",
description: "Removes the current Web Push API subscription.", description: "Removes the current Web Push API subscription.",
externalDocs: { externalDocs: {
url: "https://docs.joinmastodon.org/methods/push/#delete", url: "https://docs.joinmastodon.org/methods/push/#delete",
}, },
tags: ["Push Notifications"], tags: ["Push Notifications"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.UsePushNotifications],
scopes: ["push"],
}),
] as const,
responses: { responses: {
200: { 200: {
description: description:
"PushSubscription successfully deleted or did not exist previously.", "PushSubscription successfully deleted or did not exist previously.",
content: { content: {
"application/json": { "application/json": {
schema: z.object({}), schema: resolver(z.object({})),
}, },
}, },
}, },
@ -36,6 +30,11 @@ export default apiRoute((app) =>
422: ApiError.validationFailed().schema, 422: ApiError.validationFailed().schema,
}, },
}), }),
auth({
auth: true,
permissions: [RolePermission.UsePushNotifications],
scopes: ["push"],
}),
async (context) => { async (context) => {
const { token } = context.get("auth"); const { token } = context.get("auth");

View file

@ -1,15 +1,15 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { WebPushSubscription as WebPushSubscriptionSchema } from "@versia/client/schemas"; import { WebPushSubscription as WebPushSubscriptionSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { PushSubscription } from "@versia/kit/db"; import { PushSubscription } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi( app.get(
createRoute({ "/api/v1/push/subscription",
method: "get", describeRoute({
path: "/api/v1/push/subscription",
summary: "Get current subscription", summary: "Get current subscription",
description: description:
"View the PushSubscription currently associated with this access token.", "View the PushSubscription currently associated with this access token.",
@ -17,19 +17,12 @@ export default apiRoute((app) =>
url: "https://docs.joinmastodon.org/methods/push/#get", url: "https://docs.joinmastodon.org/methods/push/#get",
}, },
tags: ["Push Notifications"], tags: ["Push Notifications"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.UsePushNotifications],
scopes: ["push"],
}),
] as const,
responses: { responses: {
200: { 200: {
description: "WebPushSubscription", description: "WebPushSubscription",
content: { content: {
"application/json": { "application/json": {
schema: WebPushSubscriptionSchema, schema: resolver(WebPushSubscriptionSchema),
}, },
}, },
}, },
@ -37,6 +30,11 @@ export default apiRoute((app) =>
422: ApiError.validationFailed().schema, 422: ApiError.validationFailed().schema,
}, },
}), }),
auth({
auth: true,
permissions: [RolePermission.UsePushNotifications],
scopes: ["push"],
}),
async (context) => { async (context) => {
const { token } = context.get("auth"); const { token } = context.get("auth");

View file

@ -1,19 +1,19 @@
import { apiRoute } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import { auth, jsonOrForm } from "@/api"; import { auth, jsonOrForm } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { import {
WebPushSubscriptionInput, WebPushSubscriptionInput,
WebPushSubscription as WebPushSubscriptionSchema, WebPushSubscription as WebPushSubscriptionSchema,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { PushSubscription } from "@versia/kit/db"; import { PushSubscription } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi( app.post(
createRoute({ "/api/v1/push/subscription",
method: "post", describeRoute({
path: "/api/v1/push/subscription",
summary: "Subscribe to push notifications", summary: "Subscribe to push notifications",
description: description:
"Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.", "Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.",
@ -21,30 +21,13 @@ export default apiRoute((app) =>
url: "https://docs.joinmastodon.org/methods/push/#create", url: "https://docs.joinmastodon.org/methods/push/#create",
}, },
tags: ["Push Notifications"], tags: ["Push Notifications"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.UsePushNotifications],
scopes: ["push"],
}),
jsonOrForm(),
] as const,
request: {
body: {
content: {
"application/json": {
schema: WebPushSubscriptionInput,
},
},
},
},
responses: { responses: {
200: { 200: {
description: description:
"A new PushSubscription has been generated, which will send the requested alerts to your endpoint.", "A new PushSubscription has been generated, which will send the requested alerts to your endpoint.",
content: { content: {
"application/json": { "application/json": {
schema: WebPushSubscriptionSchema, schema: resolver(WebPushSubscriptionSchema),
}, },
}, },
}, },
@ -52,6 +35,13 @@ export default apiRoute((app) =>
422: ApiError.validationFailed().schema, 422: ApiError.validationFailed().schema,
}, },
}), }),
auth({
auth: true,
permissions: [RolePermission.UsePushNotifications],
scopes: ["push"],
}),
jsonOrForm(),
validator("json", WebPushSubscriptionInput, handleZodError),
async (context) => { async (context) => {
const { user, token } = context.get("auth"); const { user, token } = context.get("auth");
const { subscription, data, policy } = context.req.valid("json"); const { subscription, data, policy } = context.req.valid("json");

Some files were not shown because too many files have changed in this diff Show more