refactor(api): 🏷️ Port more misc endpoints to use new schemas
Some checks failed
Mirror to Codeberg / Mirror (push) Failing after 0s

This commit is contained in:
Jesse Wierzbinski 2025-02-13 02:34:44 +01:00
parent e3e285571e
commit 247a8fbce3
No known key found for this signature in database
16 changed files with 462 additions and 351 deletions

View file

@ -1,4 +1,4 @@
import { apiRoute, auth, jsonOrForm } from "@/api"; import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api";
import { tempmailDomains } from "@/tempmail"; import { tempmailDomains } from "@/tempmail";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
@ -74,8 +74,9 @@ const route = createRoute({
}, },
responses: { responses: {
200: { 200: {
description: "Account created", description: "Token for the created account",
}, },
401: reusedResponses[401],
422: { 422: {
description: "Validation failed", description: "Validation failed",
content: { content: {
@ -346,9 +347,9 @@ export default apiRoute((app) =>
} }
await User.fromDataLocal({ await User.fromDataLocal({
username: username ?? "", username: username,
password: password ?? "", password: password,
email: email ?? "", email: email,
}); });
return context.text("", 200); return context.text("", 200);

View file

@ -1,62 +1,61 @@
import { apiRoute, jsonOrForm } from "@/api"; import { apiRoute, jsonOrForm, reusedResponses } from "@/api";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Application } from "@versia/kit/db"; import { Application } from "@versia/kit/db";
import {
const schemas = { Application as ApplicationSchema,
json: z.object({ CredentialApplication as CredentialApplicationSchema,
client_name: z.string().trim().min(1).max(100), } from "~/classes/schemas/application";
redirect_uris: z
.string()
.min(0)
.max(2000)
.url()
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
scopes: z.string().min(1).max(200),
website: z
.string()
.min(0)
.max(2000)
.url()
.optional()
// Allow empty websites because Traewelling decides to give an empty
// value instead of not providing anything at all
.or(z.literal("").transform(() => undefined)),
}),
};
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/apps", path: "/api/v1/apps",
summary: "Create app", summary: "Create an application",
description: "Create an OAuth2 app", description: "Create a new application to obtain OAuth2 credentials.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/apps/#create",
},
tags: ["Apps"],
middleware: [jsonOrForm()], middleware: [jsonOrForm()],
request: { request: {
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: schemas.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: { responses: {
200: { 200: {
description: "App", description:
"Store the client_id and client_secret in your cache, as these will be used to obtain OAuth tokens.",
content: { content: {
"application/json": { "application/json": {
schema: z.object({ schema: CredentialApplicationSchema,
id: z.string().uuid(),
name: z.string(),
website: z.string().nullable(),
client_id: z.string(),
client_secret: z.string(),
redirect_uri: z.string(),
vapid_link: z.string().nullable(),
}),
}, },
}, },
}, },
422: reusedResponses[422],
}, },
}); });
@ -66,25 +65,14 @@ export default apiRoute((app) =>
context.req.valid("json"); context.req.valid("json");
const app = await Application.insert({ const app = await Application.insert({
name: client_name || "", name: client_name,
redirectUri: decodeURI(redirect_uris) || "", redirectUri: redirect_uris.join("\n"),
scopes: scopes || "read", scopes: scopes.join(" "),
website: website || null, website: website,
clientId: randomString(32, "base64url"), clientId: randomString(32, "base64url"),
secret: randomString(64, "base64url"), secret: randomString(64, "base64url"),
}); });
return context.json( return context.json(app.toApiCredential(), 200);
{
id: app.id,
name: app.data.name,
website: app.data.website,
client_id: app.data.clientId,
client_secret: app.data.secret,
redirect_uri: app.data.redirectUri,
vapid_link: app.data.vapidKey,
},
200,
);
}), }),
); );

View file

@ -1,16 +1,19 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { Application } from "@versia/kit/db"; import { Application } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Application as ApplicationSchema } from "~/classes/schemas/application"; import { Application as ApplicationSchema } from "~/classes/schemas/application";
import { ErrorSchema } from "~/types/api";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/apps/verify_credentials", path: "/api/v1/apps/verify_credentials",
summary: "Verify credentials", summary: "Verify your app works",
description: "Get your own application information", description: "Confirm that the apps OAuth2 credentials work.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/apps/#verify_credentials",
},
tags: ["Apps"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -19,21 +22,15 @@ const route = createRoute({
] as const, ] as const,
responses: { responses: {
200: { 200: {
description: "Application", description:
"If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.",
content: { content: {
"application/json": { "application/json": {
schema: ApplicationSchema, schema: ApplicationSchema,
}, },
}, },
}, },
401: { ...reusedResponses,
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
}, },
}); });

View file

@ -1,24 +1,19 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables"; import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Account } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
const schemas = {
query: z.object({
max_id: z.string().uuid().optional(),
since_id: z.string().uuid().optional(),
min_id: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
}),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/blocks", path: "/api/v1/blocks",
summary: "Get blocks", summary: "View your blocks.",
description: "Get users you have blocked", description: "View blocked users.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/blocks/#get",
},
tags: ["Blocks"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -27,17 +22,49 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { request: {
query: schemas.query, query: z.object({
max_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: AccountSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
description: "Maximum number of results to return.",
}),
}),
}, },
responses: { responses: {
200: { 200: {
description: "Blocks", description: "List of blocked users",
content: { content: {
"application/json": { "application/json": {
schema: z.array(Account), 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/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",
}, },
}),
}),
},
...reusedResponses,
}, },
}); });

View file

@ -1,15 +1,19 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Emoji } from "@versia/kit/db"; import { Emoji } from "@versia/kit/db";
import { Emojis, RolePermissions } from "@versia/kit/tables"; import { Emojis, RolePermissions } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { CustomEmoji } from "~/classes/schemas/emoji"; import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/custom_emojis", path: "/api/v1/custom_emojis",
summary: "Get custom emojis", summary: "View all custom emoji",
description: "Get custom emojis", description: "Returns custom emojis that are available on the server.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/custom_emojis/#get",
},
tags: ["Emojis"],
middleware: [ middleware: [
auth({ auth({
auth: false, auth: false,
@ -18,13 +22,14 @@ const route = createRoute({
] as const, ] as const,
responses: { responses: {
200: { 200: {
description: "Emojis", description: "List of custom emojis",
content: { content: {
"application/json": { "application/json": {
schema: z.array(CustomEmoji), schema: z.array(CustomEmojiSchema),
}, },
}, },
}, },
422: reusedResponses[422],
}, },
}); });

View file

@ -1,80 +1,73 @@
import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; import {
apiRoute,
auth,
jsonOrForm,
reusedResponses,
withEmojiParam,
} from "@/api";
import { mimeLookup } from "@/content_types"; import { mimeLookup } from "@/content_types";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Emoji } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { CustomEmoji } from "~/classes/schemas/emoji"; import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const schemas = { const schema = z
param: z.object({
id: z.string().uuid(),
}),
json: z
.object({ .object({
shortcode: z shortcode: CustomEmojiSchema.shape.shortcode,
.string()
.trim()
.min(1)
.max(config.validation.max_emoji_shortcode_size)
.regex(
emojiValidator,
"Shortcode must only contain letters (any case), numbers, dashes or underscores.",
),
element: z element: z
.string() .string()
.trim()
.min(1)
.max(2000)
.url() .url()
.transform((a) => new URL(a)) .transform((a) => new URL(a))
.openapi({
description: "Emoji image URL",
})
.or( .or(
z z
.instanceof(File) .instanceof(File)
.openapi({
description:
"Emoji image encoded using multipart/form-data",
})
.refine( .refine(
(v) => v.size <= config.validation.max_emoji_size, (v) => v.size <= config.validation.max_emoji_size,
`Emoji must be less than ${config.validation.max_emoji_size} bytes`, `Emoji must be less than ${config.validation.max_emoji_size} bytes`,
), ),
), ),
category: z.string().max(64).optional(), category: CustomEmojiSchema.shape.category.optional(),
alt: z alt: CustomEmojiSchema.shape.description.optional(),
.string() global: CustomEmojiSchema.shape.global.default(false),
.max(config.validation.max_emoji_description_size)
.optional(),
global: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean())
.optional(),
}) })
.partial(), .partial();
};
const routeGet = createRoute({ const routeGet = createRoute({
method: "get", method: "get",
path: "/api/v1/emojis/{id}", path: "/api/v1/emojis/{id}",
summary: "Get emoji data", summary: "Get emoji",
description: "Retrieves a custom emoji from database by ID.",
tags: ["Emojis"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
permissions: [RolePermissions.ViewEmojis], permissions: [RolePermissions.ViewEmojis],
}), }),
withEmojiParam,
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: CustomEmojiSchema.shape.id,
}),
}, },
responses: { responses: {
200: { 200: {
description: "Emoji", description: "Emoji",
content: { content: {
"application/json": { "application/json": {
schema: CustomEmoji, schema: CustomEmojiSchema,
}, },
}, },
}, },
404: { 404: {
description: "Emoji not found", description: "Emoji not found",
content: { content: {
@ -83,6 +76,7 @@ const routeGet = createRoute({
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
@ -90,6 +84,8 @@ const routePatch = createRoute({
method: "patch", method: "patch",
path: "/api/v1/emojis/{id}", path: "/api/v1/emojis/{id}",
summary: "Modify emoji", summary: "Modify emoji",
description: "Edit image or metadata of an emoji.",
tags: ["Emojis"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -99,19 +95,22 @@ const routePatch = createRoute({
], ],
}), }),
jsonOrForm(), jsonOrForm(),
withEmojiParam,
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: CustomEmojiSchema.shape.id,
}),
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: schemas.json, schema: schema,
}, },
"application/x-www-form-urlencoded": { "application/x-www-form-urlencoded": {
schema: schemas.json, schema: schema,
}, },
"multipart/form-data": { "multipart/form-data": {
schema: schemas.json, schema: schema,
}, },
}, },
}, },
@ -121,13 +120,12 @@ const routePatch = createRoute({
description: "Emoji modified", description: "Emoji modified",
content: { content: {
"application/json": { "application/json": {
schema: CustomEmoji, schema: CustomEmojiSchema,
}, },
}, },
}, },
403: { 403: {
description: "Insufficient credentials", description: "Insufficient permissions",
content: { content: {
"application/json": { "application/json": {
schema: ErrorSchema, schema: ErrorSchema,
@ -142,14 +140,7 @@ const routePatch = createRoute({
}, },
}, },
}, },
422: { ...reusedResponses,
description: "Invalid form data",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
}, },
}); });
@ -157,6 +148,8 @@ const routeDelete = createRoute({
method: "delete", method: "delete",
path: "/api/v1/emojis/{id}", path: "/api/v1/emojis/{id}",
summary: "Delete emoji", summary: "Delete emoji",
description: "Delete a custom emoji from the database.",
tags: ["Emojis"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -165,15 +158,17 @@ const routeDelete = createRoute({
RolePermissions.ViewEmojis, RolePermissions.ViewEmojis,
], ],
}), }),
withEmojiParam,
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
id: CustomEmojiSchema.shape.id,
}),
}, },
responses: { responses: {
204: { 204: {
description: "Emoji deleted", description: "Emoji deleted",
}, },
404: { 404: {
description: "Emoji not found", description: "Emoji not found",
content: { content: {
@ -186,15 +181,9 @@ const routeDelete = createRoute({
}); });
export default apiRoute((app) => { export default apiRoute((app) => {
app.openapi(routeGet, async (context) => { app.openapi(routeGet, (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth"); const { user } = context.get("auth");
const emoji = context.get("emoji");
const emoji = await Emoji.fromId(id);
if (!emoji) {
throw new ApiError(404, "Emoji not found");
}
// Don't leak non-global emojis to non-admins // Don't leak non-global emojis to non-admins
if ( if (
@ -208,14 +197,8 @@ export default apiRoute((app) => {
}); });
app.openapi(routePatch, async (context) => { app.openapi(routePatch, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth"); const { user } = context.get("auth");
const emoji = context.get("emoji");
const emoji = await Emoji.fromId(id);
if (!emoji) {
throw new ApiError(404, "Emoji not found");
}
// Check if user is admin // Check if user is admin
if ( if (
@ -246,7 +229,7 @@ export default apiRoute((app) => {
} }
if (element) { if (element) {
// Check of emoji is an image // Check if emoji is an image
const contentType = const contentType =
element instanceof File element instanceof File
? element.type ? element.type
@ -283,14 +266,8 @@ export default apiRoute((app) => {
}); });
app.openapi(routeDelete, async (context) => { app.openapi(routeDelete, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth"); const { user } = context.get("auth");
const emoji = context.get("emoji");
const emoji = await Emoji.fromId(id);
if (!emoji) {
throw new ApiError(404, "Emoji not found");
}
// Check if user is admin // Check if user is admin
if ( if (

View file

@ -1,58 +1,44 @@
import { apiRoute, auth, emojiValidator, jsonOrForm } from "@/api"; import { apiRoute, auth, jsonOrForm, reusedResponses } from "@/api";
import { mimeLookup } from "@/content_types"; import { mimeLookup } from "@/content_types";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Emoji, Media } from "@versia/kit/db"; import { Emoji, Media } from "@versia/kit/db";
import { Emojis, RolePermissions } from "@versia/kit/tables"; import { Emojis, RolePermissions } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { CustomEmoji } from "~/classes/schemas/emoji"; import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { ErrorSchema } from "~/types/api";
const schemas = { const schema = z.object({
json: z.object({ shortcode: CustomEmojiSchema.shape.shortcode,
shortcode: z
.string()
.trim()
.min(1)
.max(config.validation.max_emoji_shortcode_size)
.regex(
emojiValidator,
"Shortcode must only contain letters (any case), numbers, dashes or underscores.",
),
element: z element: z
.string() .string()
.trim()
.min(1)
.max(2000)
.url() .url()
.transform((a) => new URL(a)) .transform((a) => new URL(a))
.openapi({
description: "Emoji image URL",
})
.or( .or(
z z
.instanceof(File) .instanceof(File)
.openapi({
description:
"Emoji image encoded using multipart/form-data",
})
.refine( .refine(
(v) => v.size <= config.validation.max_emoji_size, (v) => v.size <= config.validation.max_emoji_size,
`Emoji must be less than ${config.validation.max_emoji_size} bytes`, `Emoji must be less than ${config.validation.max_emoji_size} bytes`,
), ),
), ),
category: z.string().max(64).optional(), category: CustomEmojiSchema.shape.category.optional(),
alt: z alt: CustomEmojiSchema.shape.description.optional(),
.string() global: CustomEmojiSchema.shape.global.default(false),
.max(config.validation.max_emoji_description_size) });
.optional(),
global: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean())
.optional(),
}),
};
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/emojis", path: "/api/v1/emojis",
summary: "Upload emoji", summary: "Upload emoji",
description: "Upload an emoji", description: "Upload a new emoji to the server.",
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -67,13 +53,13 @@ const route = createRoute({
body: { body: {
content: { content: {
"application/json": { "application/json": {
schema: schemas.json, schema: schema,
}, },
"multipart/form-data": { "multipart/form-data": {
schema: schemas.json, schema: schema,
}, },
"application/x-www-form-urlencoded": { "application/x-www-form-urlencoded": {
schema: schemas.json, schema: schema,
}, },
}, },
}, },
@ -83,19 +69,11 @@ const route = createRoute({
description: "Uploaded emoji", description: "Uploaded emoji",
content: { content: {
"application/json": { "application/json": {
schema: CustomEmoji, schema: CustomEmojiSchema,
},
},
},
422: {
description: "Invalid data",
content: {
"application/json": {
schema: ErrorSchema,
}, },
}, },
}, },
...reusedResponses,
}, },
}); });
@ -145,10 +123,10 @@ export default apiRoute((app) =>
const media = const media =
element instanceof File element instanceof File
? await Media.fromFile(element, { ? await Media.fromFile(element, {
description: alt, description: alt ?? undefined,
}) })
: await Media.fromUrl(element, { : await Media.fromUrl(element, {
description: alt, description: alt ?? undefined,
}); });
const emoji = await Emoji.insert({ const emoji = await Emoji.insert({

View file

@ -1,23 +1,19 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { Notes, RolePermissions } from "@versia/kit/tables"; import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Status } from "~/classes/schemas/status"; import { Status as StatusSchema } from "~/classes/schemas/status";
const schemas = {
query: z.object({
max_id: z.string().uuid().optional(),
since_id: z.string().uuid().optional(),
min_id: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
}),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/favourites", path: "/api/v1/favourites",
summary: "Get favourites", summary: "View favourited statuses",
description: "Statuses the user has favourited.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/favourites/#get",
},
tags: ["Favourites"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -25,17 +21,49 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { request: {
query: schemas.query, query: z.object({
max_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: StatusSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: StatusSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
description: "Maximum number of results to return.",
}),
}),
}, },
responses: { responses: {
200: { 200: {
description: "Favourites", description: "List of favourited statuses",
content: { content: {
"application/json": { "application/json": {
schema: z.array(Status), schema: 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",
}, },
}),
}),
},
...reusedResponses,
}, },
}); });

View file

@ -1,21 +1,19 @@
import { apiRoute, auth } from "@/api"; import { accountNotFound, apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db"; import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Account as AccountSchema } from "~/classes/schemas/account";
import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship";
import { ErrorSchema } from "~/types/api";
const schemas = {
param: z.object({
account_id: z.string().uuid(),
}),
};
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/follow_requests/{account_id}/authorize", path: "/api/v1/follow_requests/{account_id}/authorize",
summary: "Authorize follow request", summary: "Accept follow request",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/follow_requests/#accept",
},
tags: ["Follows"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -23,26 +21,22 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
account_id: AccountSchema.shape.id,
}),
}, },
responses: { responses: {
200: { 200: {
description: "Relationship", description:
"Your Relationship with this account should be updated so that you are followed_by this account.",
content: { content: {
"application/json": { "application/json": {
schema: RelationshipSchema, schema: RelationshipSchema,
}, },
}, },
}, },
404: accountNotFound,
404: { ...reusedResponses,
description: "Account not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
}, },
}); });

View file

@ -1,21 +1,19 @@
import { apiRoute, auth } from "@/api"; import { accountNotFound, apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db"; import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Account as AccountSchema } from "~/classes/schemas/account";
import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship";
import { ErrorSchema } from "~/types/api";
const schemas = {
param: z.object({
account_id: z.string().uuid(),
}),
};
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/api/v1/follow_requests/{account_id}/reject", path: "/api/v1/follow_requests/{account_id}/reject",
summary: "Reject follow request", summary: "Reject follow request",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/follow_requests/#reject",
},
tags: ["Follows"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -23,26 +21,22 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { request: {
params: schemas.param, params: z.object({
account_id: AccountSchema.shape.id,
}),
}, },
responses: { responses: {
200: { 200: {
description: "Relationship", description:
"Your Relationship with this account should be unchanged.",
content: { content: {
"application/json": { "application/json": {
schema: RelationshipSchema, schema: RelationshipSchema,
}, },
}, },
}, },
404: accountNotFound,
404: { ...reusedResponses,
description: "Account not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
}, },
}); });

View file

@ -1,23 +1,19 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables"; import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Account } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
const schemas = {
query: z.object({
max_id: z.string().uuid().optional(),
since_id: z.string().uuid().optional(),
min_id: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
}),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/follow_requests", path: "/api/v1/follow_requests",
summary: "Get follow requests", summary: "View pending follow requests",
description: "Get a list of follow requests that the user has received.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/follow_requests/#get",
},
tags: ["Follows"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -25,17 +21,50 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { request: {
query: schemas.query, query: z.object({
max_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: AccountSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
description: "Maximum number of results to return.",
}),
}),
}, },
responses: { responses: {
200: { 200: {
description: "Follow requests", description:
"List of accounts that have requested to follow the user",
content: { content: {
"application/json": { "application/json": {
schema: z.array(Account), 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/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",
}, },
}),
}),
},
...reusedResponses,
}, },
}); });

View file

@ -1,23 +1,19 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth, reusedResponses } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables"; import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Account } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
const schemas = {
query: z.object({
max_id: z.string().uuid().optional(),
since_id: z.string().uuid().optional(),
min_id: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
}),
};
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
path: "/api/v1/mutes", path: "/api/v1/mutes",
summary: "Get muted users", summary: "View muted accounts",
description: "View your mutes.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/mutes/#get",
},
tags: ["Mutes"],
middleware: [ middleware: [
auth({ auth({
auth: true, auth: true,
@ -26,17 +22,49 @@ const route = createRoute({
}), }),
] as const, ] as const,
request: { request: {
query: schemas.query, query: z.object({
max_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}),
since_id: AccountSchema.shape.id.optional().openapi({
description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined,
}),
min_id: AccountSchema.shape.id.optional().openapi({
description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined,
}),
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
description: "Maximum number of results to return.",
}),
}),
}, },
responses: { responses: {
200: { 200: {
description: "Muted users", description: "List of muted users",
content: { content: {
"application/json": { "application/json": {
schema: z.array(Account), 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/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",
}, },
}),
}),
},
...reusedResponses,
}, },
}); });

View file

@ -1,7 +1,12 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
export const Application = z.object({ export const Application = z.object({
name: z.string().openapi({ name: z
.string()
.trim()
.min(1)
.max(200)
.openapi({
description: "The name of your application.", description: "The name of your application.",
example: "Test Application", example: "Test Application",
externalDocs: { externalDocs: {
@ -18,7 +23,10 @@ export const Application = z.object({
url: "https://docs.joinmastodon.org/entities/Application/#website", url: "https://docs.joinmastodon.org/entities/Application/#website",
}, },
}), }),
scopes: z.array(z.string()).openapi({ scopes: z
.array(z.string())
.default(["read"])
.openapi({
description: description:
"The scopes for your application. This is the registered scopes string split on whitespace.", "The scopes for your application. This is the registered scopes string split on whitespace.",
example: ["read", "write", "push"], example: ["read", "write", "push"],
@ -28,7 +36,11 @@ export const Application = z.object({
}), }),
redirect_uris: z redirect_uris: z
.array( .array(
z.string().url().openapi({ z
.string()
.url()
.or(z.literal("urn:ietf:wg:oauth:2.0:oob"))
.openapi({
description: "URL or 'urn:ietf:wg:oauth:2.0:oob'", description: "URL or 'urn:ietf:wg:oauth:2.0:oob'",
}), }),
) )
@ -40,6 +52,7 @@ export const Application = z.object({
}, },
}), }),
redirect_uri: z.string().openapi({ redirect_uri: z.string().openapi({
deprecated: true,
description: description:
"The registered redirection URI(s) for your application. May contain \\n characters when multiple redirect URIs are registered.", "The registered redirection URI(s) for your application. May contain \\n characters when multiple redirect URIs are registered.",
externalDocs: { externalDocs: {

View file

@ -1,5 +1,7 @@
import { emojiValidator } from "@/api.ts";
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { zBoolean } from "~/packages/config-manager/config.type"; import { zBoolean } from "~/packages/config-manager/config.type";
import { config } from "~/packages/config-manager/index.ts";
import { Id } from "./common.ts"; import { Id } from "./common.ts";
export const CustomEmoji = z export const CustomEmoji = z
@ -9,7 +11,16 @@ export const CustomEmoji = z
description: "ID of the custom emoji in the database.", description: "ID of the custom emoji in the database.",
example: "af9ccd29-c689-477f-aa27-d7d95fd8fb05", example: "af9ccd29-c689-477f-aa27-d7d95fd8fb05",
}), }),
shortcode: z.string().openapi({ shortcode: z
.string()
.trim()
.min(1)
.max(config.validation.max_emoji_shortcode_size)
.regex(
emojiValidator,
"Shortcode must only contain letters (any case), numbers, dashes or underscores.",
)
.openapi({
description: "The name of the custom emoji.", description: "The name of the custom emoji.",
example: "blobaww", example: "blobaww",
externalDocs: { externalDocs: {
@ -48,6 +59,8 @@ export const CustomEmoji = z
}), }),
category: z category: z
.string() .string()
.trim()
.max(64)
.nullable() .nullable()
.openapi({ .openapi({
description: "Used for sorting custom emoji in the picker.", description: "Used for sorting custom emoji in the picker.",
@ -64,6 +77,7 @@ export const CustomEmoji = z
/* Versia Server API extension */ /* Versia Server API extension */
description: z description: z
.string() .string()
.max(config.validation.max_emoji_description_size)
.nullable() .nullable()
.openapi({ .openapi({
description: description:

View file

@ -49,13 +49,14 @@ describe("POST /api/v1/apps/", () => {
const json = await response.json(); const json = await response.json();
expect(json).toEqual({ expect(json).toEqual({
id: expect.any(String),
name: "Test Application", name: "Test Application",
website: "https://example.com", website: "https://example.com",
client_id: expect.any(String), client_id: expect.any(String),
client_secret: expect.any(String), client_secret: expect.any(String),
client_secret_expires_at: "0",
redirect_uri: "https://example.com", redirect_uri: "https://example.com",
vapid_link: null, redirect_uris: ["https://example.com"],
scopes: ["read", "write"],
}); });
clientId = json.client_id; clientId = json.client_id;

View file

@ -2,7 +2,7 @@ import type { OpenAPIHono } from "@hono/zod-openapi";
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import { Application, Note, Token, User, db } from "@versia/kit/db"; import { Application, Emoji, Note, Token, User, db } from "@versia/kit/db";
import { Challenges, type RolePermissions } from "@versia/kit/tables"; import { Challenges, type RolePermissions } from "@versia/kit/tables";
import { extractParams, verifySolution } from "altcha-lib"; import { extractParams, verifySolution } from "altcha-lib";
import chalk from "chalk"; import chalk from "chalk";
@ -404,6 +404,43 @@ export const withUserParam = every(
} }
>; >;
/**
* Middleware to check if an emoji exists and is viewable by the user
*
* Useful in /api/v1/emojis/:id/* routes
* @returns
*/
export const withEmojiParam = every(
zValidator("param", z.object({ id: z.string().uuid() }), handleZodError),
createMiddleware<
HonoEnv & {
Variables: {
emoji: Emoji;
};
},
string,
WithIdParam
>(async (context, next) => {
const { id } = context.req.valid("param");
const emoji = await Emoji.fromId(id);
if (!emoji) {
throw new ApiError(404, "Emoji not found");
}
context.set("emoji", emoji);
await next();
}),
) as MiddlewareHandler<
HonoEnv & {
Variables: {
emoji: Emoji;
};
}
>;
// Helper function to parse form data // Helper function to parse form data
async function parseFormData(context: Context): Promise<{ async function parseFormData(context: Context): Promise<{
parsed: ParsedQs; parsed: ParsedQs;