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 { createRoute, z } from "@hono/zod-openapi";
import { User } from "@versia/kit/db";
@ -74,8 +74,9 @@ const route = createRoute({
},
responses: {
200: {
description: "Account created",
description: "Token for the created account",
},
401: reusedResponses[401],
422: {
description: "Validation failed",
content: {
@ -346,9 +347,9 @@ export default apiRoute((app) =>
}
await User.fromDataLocal({
username: username ?? "",
password: password ?? "",
email: email ?? "",
username: username,
password: password,
email: email,
});
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 { createRoute, z } from "@hono/zod-openapi";
import { Application } from "@versia/kit/db";
const schemas = {
json: z.object({
client_name: z.string().trim().min(1).max(100),
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)),
}),
};
import {
Application as ApplicationSchema,
CredentialApplication as CredentialApplicationSchema,
} from "~/classes/schemas/application";
const route = createRoute({
method: "post",
path: "/api/v1/apps",
summary: "Create app",
description: "Create an OAuth2 app",
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()],
request: {
body: {
content: {
"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: {
200: {
description: "App",
description:
"Store the client_id and client_secret in your cache, as these will be used to obtain OAuth tokens.",
content: {
"application/json": {
schema: z.object({
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(),
}),
schema: CredentialApplicationSchema,
},
},
},
422: reusedResponses[422],
},
});
@ -66,25 +65,14 @@ export default apiRoute((app) =>
context.req.valid("json");
const app = await Application.insert({
name: client_name || "",
redirectUri: decodeURI(redirect_uris) || "",
scopes: scopes || "read",
website: website || null,
name: client_name,
redirectUri: redirect_uris.join("\n"),
scopes: scopes.join(" "),
website: website,
clientId: randomString(32, "base64url"),
secret: randomString(64, "base64url"),
});
return context.json(
{
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,
);
return context.json(app.toApiCredential(), 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 { Application } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error";
import { Application as ApplicationSchema } from "~/classes/schemas/application";
import { ErrorSchema } from "~/types/api";
const route = createRoute({
method: "get",
path: "/api/v1/apps/verify_credentials",
summary: "Verify credentials",
description: "Get your own application information",
summary: "Verify your app works",
description: "Confirm that the apps OAuth2 credentials work.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/apps/#verify_credentials",
},
tags: ["Apps"],
middleware: [
auth({
auth: true,
@ -19,21 +22,15 @@ const route = createRoute({
] as const,
responses: {
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: {
"application/json": {
schema: ApplicationSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
...reusedResponses,
},
});

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 { Timeline } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Account } 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),
}),
};
import { Account as AccountSchema } from "~/classes/schemas/account";
const route = createRoute({
method: "get",
path: "/api/v1/blocks",
summary: "Get blocks",
description: "Get users you have blocked",
summary: "View your blocks.",
description: "View blocked users.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/blocks/#get",
},
tags: ["Blocks"],
middleware: [
auth({
auth: true,
@ -27,17 +22,49 @@ const route = createRoute({
}),
] as const,
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: {
200: {
description: "Blocks",
description: "List of blocked users",
content: {
"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 { Emoji } from "@versia/kit/db";
import { Emojis, RolePermissions } from "@versia/kit/tables";
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({
method: "get",
path: "/api/v1/custom_emojis",
summary: "Get custom emojis",
description: "Get custom emojis",
summary: "View all custom emoji",
description: "Returns custom emojis that are available on the server.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/custom_emojis/#get",
},
tags: ["Emojis"],
middleware: [
auth({
auth: false,
@ -18,13 +22,14 @@ const route = createRoute({
] as const,
responses: {
200: {
description: "Emojis",
description: "List of custom emojis",
content: {
"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 { createRoute, z } from "@hono/zod-openapi";
import { Emoji } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
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 { ErrorSchema } from "~/types/api";
const schemas = {
param: z.object({
id: z.string().uuid(),
}),
json: z
.object({
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
.string()
.trim()
.min(1)
.max(2000)
.url()
.transform((a) => new URL(a))
.or(
z
.instanceof(File)
.refine(
(v) => v.size <= config.validation.max_emoji_size,
`Emoji must be less than ${config.validation.max_emoji_size} bytes`,
),
),
category: z.string().max(64).optional(),
alt: z
.string()
.max(config.validation.max_emoji_description_size)
.optional(),
global: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean())
.optional(),
})
.partial(),
};
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.max_emoji_size,
`Emoji must be less than ${config.validation.max_emoji_size} 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 data",
summary: "Get emoji",
description: "Retrieves a custom emoji from database by ID.",
tags: ["Emojis"],
middleware: [
auth({
auth: true,
permissions: [RolePermissions.ViewEmojis],
}),
withEmojiParam,
] as const,
request: {
params: schemas.param,
params: z.object({
id: CustomEmojiSchema.shape.id,
}),
},
responses: {
200: {
description: "Emoji",
content: {
"application/json": {
schema: CustomEmoji,
schema: CustomEmojiSchema,
},
},
},
404: {
description: "Emoji not found",
content: {
@ -83,6 +76,7 @@ const routeGet = createRoute({
},
},
},
...reusedResponses,
},
});
@ -90,6 +84,8 @@ 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,
@ -99,19 +95,22 @@ const routePatch = createRoute({
],
}),
jsonOrForm(),
withEmojiParam,
] as const,
request: {
params: schemas.param,
params: z.object({
id: CustomEmojiSchema.shape.id,
}),
body: {
content: {
"application/json": {
schema: schemas.json,
schema: schema,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
schema: schema,
},
"multipart/form-data": {
schema: schemas.json,
schema: schema,
},
},
},
@ -121,13 +120,12 @@ const routePatch = createRoute({
description: "Emoji modified",
content: {
"application/json": {
schema: CustomEmoji,
schema: CustomEmojiSchema,
},
},
},
403: {
description: "Insufficient credentials",
description: "Insufficient permissions",
content: {
"application/json": {
schema: ErrorSchema,
@ -142,14 +140,7 @@ const routePatch = createRoute({
},
},
},
422: {
description: "Invalid form data",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
...reusedResponses,
},
});
@ -157,6 +148,8 @@ 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,
@ -165,15 +158,17 @@ const routeDelete = createRoute({
RolePermissions.ViewEmojis,
],
}),
withEmojiParam,
] as const,
request: {
params: schemas.param,
params: z.object({
id: CustomEmojiSchema.shape.id,
}),
},
responses: {
204: {
description: "Emoji deleted",
},
404: {
description: "Emoji not found",
content: {
@ -186,15 +181,9 @@ const routeDelete = createRoute({
});
export default apiRoute((app) => {
app.openapi(routeGet, async (context) => {
const { id } = context.req.valid("param");
app.openapi(routeGet, (context) => {
const { user } = context.get("auth");
const emoji = await Emoji.fromId(id);
if (!emoji) {
throw new ApiError(404, "Emoji not found");
}
const emoji = context.get("emoji");
// Don't leak non-global emojis to non-admins
if (
@ -208,14 +197,8 @@ export default apiRoute((app) => {
});
app.openapi(routePatch, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const emoji = await Emoji.fromId(id);
if (!emoji) {
throw new ApiError(404, "Emoji not found");
}
const emoji = context.get("emoji");
// Check if user is admin
if (
@ -246,7 +229,7 @@ export default apiRoute((app) => {
}
if (element) {
// Check of emoji is an image
// Check if emoji is an image
const contentType =
element instanceof File
? element.type
@ -283,14 +266,8 @@ export default apiRoute((app) => {
});
app.openapi(routeDelete, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const emoji = await Emoji.fromId(id);
if (!emoji) {
throw new ApiError(404, "Emoji not found");
}
const emoji = context.get("emoji");
// Check if user is admin
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 { createRoute, z } from "@hono/zod-openapi";
import { Emoji, Media } from "@versia/kit/db";
import { Emojis, RolePermissions } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm";
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 { ErrorSchema } from "~/types/api";
const schemas = {
json: z.object({
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
.string()
.trim()
.min(1)
.max(2000)
.url()
.transform((a) => new URL(a))
.or(
z
.instanceof(File)
.refine(
(v) => v.size <= config.validation.max_emoji_size,
`Emoji must be less than ${config.validation.max_emoji_size} bytes`,
),
),
category: z.string().max(64).optional(),
alt: z
.string()
.max(config.validation.max_emoji_description_size)
.optional(),
global: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean())
.optional(),
}),
};
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.max_emoji_size,
`Emoji must be less than ${config.validation.max_emoji_size} 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 an emoji",
description: "Upload a new emoji to the server.",
middleware: [
auth({
auth: true,
@ -67,13 +53,13 @@ const route = createRoute({
body: {
content: {
"application/json": {
schema: schemas.json,
schema: schema,
},
"multipart/form-data": {
schema: schemas.json,
schema: schema,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
schema: schema,
},
},
},
@ -83,19 +69,11 @@ const route = createRoute({
description: "Uploaded emoji",
content: {
"application/json": {
schema: CustomEmoji,
},
},
},
422: {
description: "Invalid data",
content: {
"application/json": {
schema: ErrorSchema,
schema: CustomEmojiSchema,
},
},
},
...reusedResponses,
},
});
@ -145,10 +123,10 @@ export default apiRoute((app) =>
const media =
element instanceof File
? await Media.fromFile(element, {
description: alt,
description: alt ?? undefined,
})
: await Media.fromUrl(element, {
description: alt,
description: alt ?? undefined,
});
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 { Timeline } from "@versia/kit/db";
import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Status } 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),
}),
};
import { Status as StatusSchema } from "~/classes/schemas/status";
const route = createRoute({
method: "get",
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: [
auth({
auth: true,
@ -25,17 +21,49 @@ const route = createRoute({
}),
] as const,
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: {
200: {
description: "Favourites",
description: "List of favourited statuses",
content: {
"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 { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error";
import { Account as AccountSchema } from "~/classes/schemas/account";
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({
method: "post",
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: [
auth({
auth: true,
@ -23,26 +21,22 @@ const route = createRoute({
}),
] as const,
request: {
params: schemas.param,
params: z.object({
account_id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description: "Relationship",
description:
"Your Relationship with this account should be updated so that you are followed_by this account.",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: {
description: "Account not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: accountNotFound,
...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 { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error";
import { Account as AccountSchema } from "~/classes/schemas/account";
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({
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,
@ -23,26 +21,22 @@ const route = createRoute({
}),
] as const,
request: {
params: schemas.param,
params: z.object({
account_id: AccountSchema.shape.id,
}),
},
responses: {
200: {
description: "Relationship",
description:
"Your Relationship with this account should be unchanged.",
content: {
"application/json": {
schema: RelationshipSchema,
},
},
},
404: {
description: "Account not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: accountNotFound,
...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 { Timeline } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Account } 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),
}),
};
import { Account as AccountSchema } from "~/classes/schemas/account";
const route = createRoute({
method: "get",
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: [
auth({
auth: true,
@ -25,17 +21,50 @@ const route = createRoute({
}),
] as const,
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: {
200: {
description: "Follow requests",
description:
"List of accounts that have requested to follow the user",
content: {
"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 { Timeline } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { Account } 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),
}),
};
import { Account as AccountSchema } from "~/classes/schemas/account";
const route = createRoute({
method: "get",
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: [
auth({
auth: true,
@ -26,17 +22,49 @@ const route = createRoute({
}),
] as const,
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: {
200: {
description: "Muted users",
description: "List of muted users",
content: {
"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,13 +1,18 @@
import { z } from "@hono/zod-openapi";
export const Application = z.object({
name: z.string().openapi({
description: "The name of your application.",
example: "Test Application",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Application/#name",
},
}),
name: z
.string()
.trim()
.min(1)
.max(200)
.openapi({
description: "The name of your application.",
example: "Test Application",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Application/#name",
},
}),
website: z
.string()
.nullable()
@ -18,19 +23,26 @@ export const Application = z.object({
url: "https://docs.joinmastodon.org/entities/Application/#website",
},
}),
scopes: z.array(z.string()).openapi({
description:
"The scopes for your application. This is the registered scopes string split on whitespace.",
example: ["read", "write", "push"],
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Application/#scopes",
},
}),
scopes: z
.array(z.string())
.default(["read"])
.openapi({
description:
"The scopes for your application. This is the registered scopes string split on whitespace.",
example: ["read", "write", "push"],
externalDocs: {
url: "https://docs.joinmastodon.org/entities/Application/#scopes",
},
}),
redirect_uris: z
.array(
z.string().url().openapi({
description: "URL or 'urn:ietf:wg:oauth:2.0:oob'",
}),
z
.string()
.url()
.or(z.literal("urn:ietf:wg:oauth:2.0:oob"))
.openapi({
description: "URL or 'urn:ietf:wg:oauth:2.0:oob'",
}),
)
.openapi({
description:
@ -40,6 +52,7 @@ export const Application = z.object({
},
}),
redirect_uri: z.string().openapi({
deprecated: true,
description:
"The registered redirection URI(s) for your application. May contain \\n characters when multiple redirect URIs are registered.",
externalDocs: {

View file

@ -1,5 +1,7 @@
import { emojiValidator } from "@/api.ts";
import { z } from "@hono/zod-openapi";
import { zBoolean } from "~/packages/config-manager/config.type";
import { config } from "~/packages/config-manager/index.ts";
import { Id } from "./common.ts";
export const CustomEmoji = z
@ -9,13 +11,22 @@ export const CustomEmoji = z
description: "ID of the custom emoji in the database.",
example: "af9ccd29-c689-477f-aa27-d7d95fd8fb05",
}),
shortcode: z.string().openapi({
description: "The name of the custom emoji.",
example: "blobaww",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/CustomEmoji/#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.",
)
.openapi({
description: "The name of the custom emoji.",
example: "blobaww",
externalDocs: {
url: "https://docs.joinmastodon.org/entities/CustomEmoji/#shortcode",
},
}),
url: z
.string()
.url()
@ -48,6 +59,8 @@ export const CustomEmoji = z
}),
category: z
.string()
.trim()
.max(64)
.nullable()
.openapi({
description: "Used for sorting custom emoji in the picker.",
@ -64,6 +77,7 @@ export const CustomEmoji = z
/* Versia Server API extension */
description: z
.string()
.max(config.validation.max_emoji_description_size)
.nullable()
.openapi({
description:

View file

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

View file

@ -2,7 +2,7 @@ import type { OpenAPIHono } from "@hono/zod-openapi";
import { z } from "@hono/zod-openapi";
import { zValidator } from "@hono/zod-validator";
import { getLogger } from "@logtape/logtape";
import { 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 { extractParams, verifySolution } from "altcha-lib";
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
async function parseFormData(context: Context): Promise<{
parsed: ParsedQs;