refactor(api): ♻️ More OpenAPI refactoring work

This commit is contained in:
Jesse Wierzbinski 2024-09-16 15:29:09 +02:00
parent 6d9e385a04
commit 5aa1c4e625
No known key found for this signature in database
35 changed files with 4883 additions and 1815 deletions

View file

@ -1,13 +1,6 @@
import {
apiRoute,
applyConfig,
auth,
emojiValidator,
handleZodError,
jsonOrForm,
} from "@/api";
import { apiRoute, applyConfig, auth, emojiValidator, jsonOrForm } from "@/api";
import { mimeLookup } from "@/content_types";
import { zValidator } from "@hono/zod-validator";
import { createRoute } from "@hono/zod-openapi";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { MediaManager } from "~/classes/media/media-manager";
@ -16,6 +9,7 @@ import { Emojis, RolePermissions } from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
import { Attachment } from "~/packages/database-interface/attachment";
import { Emoji } from "~/packages/database-interface/emoji";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
allowedMethods: ["DELETE", "GET", "PATCH"],
@ -62,146 +56,302 @@ export const schemas = {
.or(z.boolean())
.optional(),
})
.partial()
.optional(),
.partial(),
};
export default apiRoute((app) =>
app.on(
meta.allowedMethods,
meta.route,
jsonOrForm(),
zValidator("param", schemas.param, handleZodError),
zValidator("json", schemas.json, handleZodError),
auth(meta.auth, meta.permissions),
async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const routeGet = createRoute({
method: "get",
path: "/api/v1/emojis/{id}",
summary: "Get emoji data",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Emoji",
content: {
"application/json": {
schema: Emoji.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
403: {
description: "Insufficient credentials",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const routePatch = createRoute({
method: "patch",
path: "/api/v1/emojis/{id}",
summary: "Modify emoji",
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
request: {
params: schemas.param,
body: {
content: {
"application/json": {
schema: schemas.json,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
},
"multipart/form-data": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Emoji modified",
content: {
"application/json": {
schema: Emoji.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
403: {
description: "Insufficient credentials",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
422: {
description: "Invalid form data",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
const emoji = await Emoji.fromId(id);
const routeDelete = createRoute({
method: "delete",
path: "/api/v1/emojis/{id}",
summary: "Delete emoji",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
204: {
description: "Emoji deleted",
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
if (!emoji) {
return context.json({ error: "Emoji not found" }, 404);
}
export default apiRoute((app) => {
app.openapi(routeGet, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
// Check if user is admin
if (
!user.hasPermission(RolePermissions.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const emoji = await Emoji.fromId(id);
if (!emoji) {
return context.json({ error: "Emoji not found" }, 404);
}
// Check if user is admin
if (
!user.hasPermission(RolePermissions.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
return context.json(
{
error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
},
403,
);
}
return context.json(emoji.toApi(), 200);
});
app.openapi(routePatch, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const emoji = await Emoji.fromId(id);
if (!emoji) {
return context.json({ error: "Emoji not found" }, 404);
}
// Check if user is admin
if (
!user.hasPermission(RolePermissions.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
return context.json(
{
error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
},
403,
);
}
const mediaManager = new MediaManager(config);
const {
global: emojiGlobal,
alt,
category,
element,
shortcode,
} = context.req.valid("json");
if (!user.hasPermission(RolePermissions.ManageEmojis) && emojiGlobal) {
return context.json(
{
error: `Only users with the '${RolePermissions.ManageEmojis}' permission can make an emoji global or not`,
},
401,
);
}
const modified = structuredClone(emoji.data);
if (element) {
// Check of emoji is an image
let contentType =
element instanceof File
? element.type
: await mimeLookup(element);
if (!contentType.startsWith("image/")) {
return context.json(
{
error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
},
403,
422,
);
}
const mediaManager = new MediaManager(config);
let url = "";
switch (context.req.method) {
case "DELETE": {
await mediaManager.deleteFileByUrl(emoji.data.url);
if (element instanceof File) {
const uploaded = await mediaManager.addFile(element);
await db.delete(Emojis).where(eq(Emojis.id, id));
return context.newResponse(null, 204);
}
case "PATCH": {
const form = context.req.valid("json");
if (!form) {
return context.json(
{
error: "Invalid form data (must supply at least one of: shortcode, element, alt, category)",
},
422,
);
}
if (
!(
form.shortcode ||
form.element ||
form.alt ||
form.category
) &&
form.global === undefined
) {
return context.json(
{
error: "Invalid form data (must supply at least one of: shortcode, element, alt, category)",
},
422,
);
}
if (
!user.hasPermission(RolePermissions.ManageEmojis) &&
form.global
) {
return context.json(
{
error: `Only users with the '${RolePermissions.ManageEmojis}' permission can make an emoji global or not`,
},
401,
);
}
const modified = structuredClone(emoji.data);
if (form.element) {
// Check of emoji is an image
let contentType =
form.element instanceof File
? form.element.type
: await mimeLookup(form.element);
if (!contentType.startsWith("image/")) {
return context.json(
{
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
},
422,
);
}
let url = "";
if (form.element instanceof File) {
const uploaded = await mediaManager.addFile(
form.element,
);
url = uploaded.path;
contentType = uploaded.uploadedFile.type;
} else {
url = form.element;
}
modified.url = Attachment.getUrl(url);
modified.contentType = contentType;
}
modified.shortcode = form.shortcode ?? modified.shortcode;
modified.alt = form.alt ?? modified.alt;
modified.category = form.category ?? modified.category;
modified.ownerId = form.global ? null : user.data.id;
await emoji.update(modified);
return context.json(emoji.toApi());
}
case "GET": {
return context.json(emoji.toApi());
}
url = uploaded.path;
contentType = uploaded.uploadedFile.type;
} else {
url = element;
}
},
),
);
modified.url = Attachment.getUrl(url);
modified.contentType = contentType;
}
modified.shortcode = shortcode ?? modified.shortcode;
modified.alt = alt ?? modified.alt;
modified.category = category ?? modified.category;
modified.ownerId = emojiGlobal ? null : user.data.id;
await emoji.update(modified);
return context.json(emoji.toApi(), 200);
});
app.openapi(routeDelete, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const emoji = await Emoji.fromId(id);
if (!emoji) {
return context.json({ error: "Emoji not found" }, 404);
}
// Check if user is admin
if (
!user.hasPermission(RolePermissions.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
return context.json(
{
error: `You cannot delete this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
},
403,
);
}
const mediaManager = new MediaManager(config);
await mediaManager.deleteFileByUrl(emoji.data.url);
await db.delete(Emojis).where(eq(Emojis.id, id));
return context.newResponse(null, 204);
});
});

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { apiRoute, applyConfig } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
@ -14,21 +15,38 @@ export const meta = applyConfig({
},
});
export default apiRoute((app) =>
app.on(
meta.allowedMethods,
meta.route,
auth(meta.auth, meta.permissions),
async (context) => {
const { content, lastModified } = await renderMarkdownInPath(
config.instance.extended_description_path ?? "",
"This is a [Versia](https://versia.pub) server with the default extended description.",
);
const route = createRoute({
method: "get",
path: "/api/v1/instance/extended_description",
summary: "Get extended description",
responses: {
200: {
description: "Extended description",
content: {
"application/json": {
schema: z.object({
updated_at: z.string(),
content: z.string(),
}),
},
},
},
},
});
return context.json({
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { content, lastModified } = await renderMarkdownInPath(
config.instance.extended_description_path ?? "",
"This is a [Versia](https://versia.pub) server with the default extended description.",
);
return context.json(
{
updated_at: lastModified.toISOString(),
content,
});
},
),
},
200,
);
}),
);

View file

@ -160,6 +160,6 @@ describe(meta.route, () => {
},
);
expect(filterDeleteResponse.status).toBe(200);
expect(filterDeleteResponse.status).toBe(204);
});
});

View file

@ -190,7 +190,7 @@ describe(meta.route, () => {
},
);
expect(filterDeleteResponse.status).toBe(200);
expect(filterDeleteResponse.status).toBe(204);
});
});
});

View file

@ -234,6 +234,6 @@ describe(meta.route, () => {
},
);
expect(filterDeleteResponse.status).toBe(200);
expect(filterDeleteResponse.status).toBe(204);
});
});

View file

@ -154,7 +154,7 @@ describe(meta.route, () => {
},
);
expect(response.status).toBe(200);
expect(response.status).toBe(204);
// Try to GET the filter again
const getResponse = await fakeRequest(

View file

@ -1,9 +1,10 @@
import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api";
import { zValidator } from "@hono/zod-validator";
import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "~/drizzle/db";
import { FilterKeywords, Filters, RolePermissions } from "~/drizzle/schema";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
allowedMethods: ["GET", "PUT", "DELETE"],
@ -68,159 +69,281 @@ export const schemas = {
}),
};
export default apiRoute((app) =>
app.on(
meta.allowedMethods,
meta.route,
jsonOrForm(),
zValidator("param", schemas.param, handleZodError),
zValidator("json", schemas.json, handleZodError),
auth(meta.auth, meta.permissions),
async (context) => {
const { user } = context.get("auth");
const { id } = context.req.valid("param");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const userFilter = await db.query.Filters.findFirst({
where: (filter, { eq, and }) =>
and(eq(filter.userId, user.id), eq(filter.id, id)),
with: {
keywords: true,
},
});
if (!userFilter) {
return context.json({ error: "Filter not found" }, 404);
}
switch (context.req.method) {
case "GET": {
return context.json({
id: userFilter.id,
title: userFilter.title,
context: userFilter.context,
expires_at: userFilter.expireAt
? new Date(userFilter.expireAt).toISOString()
: null,
filter_action: userFilter.filterAction,
keywords: userFilter.keywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
});
}
case "PUT": {
const {
title,
context: ctx,
filter_action,
expires_in,
keywords_attributes,
} = context.req.valid("json");
await db
.update(Filters)
.set({
title,
context: ctx ?? [],
filterAction: filter_action,
expireAt: new Date(
Date.now() + (expires_in ?? 0),
).toISOString(),
})
.where(
and(
eq(Filters.userId, user.id),
eq(Filters.id, id),
),
);
const toUpdate = keywords_attributes
?.filter((keyword) => keyword.id && !keyword._destroy)
.map((keyword) => ({
keyword: keyword.keyword,
wholeWord: keyword.whole_word ?? false,
id: keyword.id,
}));
const toDelete = keywords_attributes
?.filter((keyword) => keyword._destroy && keyword.id)
.map((keyword) => keyword.id ?? "");
if (toUpdate && toUpdate.length > 0) {
for (const keyword of toUpdate) {
await db
.update(FilterKeywords)
.set(keyword)
.where(
and(
eq(FilterKeywords.filterId, id),
eq(FilterKeywords.id, keyword.id ?? ""),
),
);
}
}
if (toDelete && toDelete.length > 0) {
await db
.delete(FilterKeywords)
.where(
and(
eq(FilterKeywords.filterId, id),
inArray(FilterKeywords.id, toDelete),
),
);
}
const updatedFilter = await db.query.Filters.findFirst({
where: (filter, { eq, and }) =>
and(eq(filter.userId, user.id), eq(filter.id, id)),
with: {
keywords: true,
},
});
if (!updatedFilter) {
return context.json(
{ error: "Failed to update filter" },
500,
);
}
return context.json({
id: updatedFilter.id,
title: updatedFilter.title,
context: updatedFilter.context,
expires_at: updatedFilter.expireAt
? new Date(updatedFilter.expireAt).toISOString()
: null,
filter_action: updatedFilter.filterAction,
keywords: updatedFilter.keywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
});
}
case "DELETE": {
await db
.delete(Filters)
.where(
and(
eq(Filters.userId, user.id),
eq(Filters.id, id),
),
);
return context.json({});
}
}
},
const filterSchema = z.object({
id: z.string(),
title: z.string(),
context: z.array(z.string()),
expires_at: z.string().nullable(),
filter_action: z.enum(["warn", "hide"]),
keywords: z.array(
z.object({
id: z.string(),
keyword: z.string(),
whole_word: z.boolean(),
}),
),
);
statuses: z.array(z.string()),
});
const routeGet = createRoute({
method: "get",
path: "/api/v2/filters/{id}",
summary: "Get filter",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Filter",
content: {
"application/json": {
schema: filterSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Filter not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
const routePut = createRoute({
method: "put",
path: "/api/v2/filters/{id}",
summary: "Update filter",
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
request: {
params: schemas.param,
body: {
content: {
"application/json": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Filter updated",
content: {
"application/json": {
schema: filterSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Filter not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
const routeDelete = createRoute({
method: "delete",
path: "/api/v2/filters/{id}",
summary: "Delete filter",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
204: {
description: "Filter deleted",
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Filter not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) => {
app.openapi(routeGet, async (context) => {
const { user } = context.get("auth");
const { id } = context.req.valid("param");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const userFilter = await db.query.Filters.findFirst({
where: (filter, { eq, and }) =>
and(eq(filter.userId, user.id), eq(filter.id, id)),
with: {
keywords: true,
},
});
if (!userFilter) {
return context.json({ error: "Filter not found" }, 404);
}
return context.json(
{
id: userFilter.id,
title: userFilter.title,
context: userFilter.context,
expires_at: userFilter.expireAt
? new Date(userFilter.expireAt).toISOString()
: null,
filter_action: userFilter.filterAction,
keywords: userFilter.keywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
},
200,
);
});
app.openapi(routePut, async (context) => {
const { user } = context.get("auth");
const { id } = context.req.valid("param");
const {
title,
context: ctx,
filter_action,
expires_in,
keywords_attributes,
} = context.req.valid("json");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
await db
.update(Filters)
.set({
title,
context: ctx ?? [],
filterAction: filter_action,
expireAt: new Date(
Date.now() + (expires_in ?? 0),
).toISOString(),
})
.where(and(eq(Filters.userId, user.id), eq(Filters.id, id)));
const toUpdate = keywords_attributes
?.filter((keyword) => keyword.id && !keyword._destroy)
.map((keyword) => ({
keyword: keyword.keyword,
wholeWord: keyword.whole_word ?? false,
id: keyword.id,
}));
const toDelete = keywords_attributes
?.filter((keyword) => keyword._destroy && keyword.id)
.map((keyword) => keyword.id ?? "");
if (toUpdate && toUpdate.length > 0) {
for (const keyword of toUpdate) {
await db
.update(FilterKeywords)
.set(keyword)
.where(
and(
eq(FilterKeywords.filterId, id),
eq(FilterKeywords.id, keyword.id ?? ""),
),
);
}
}
if (toDelete && toDelete.length > 0) {
await db
.delete(FilterKeywords)
.where(
and(
eq(FilterKeywords.filterId, id),
inArray(FilterKeywords.id, toDelete),
),
);
}
const updatedFilter = await db.query.Filters.findFirst({
where: (filter, { eq, and }) =>
and(eq(filter.userId, user.id), eq(filter.id, id)),
with: {
keywords: true,
},
});
if (!updatedFilter) {
throw new Error("Failed to update filter");
}
return context.json(
{
id: updatedFilter.id,
title: updatedFilter.title,
context: updatedFilter.context,
expires_at: updatedFilter.expireAt
? new Date(updatedFilter.expireAt).toISOString()
: null,
filter_action: updatedFilter.filterAction,
keywords: updatedFilter.keywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
},
200,
);
});
app.openapi(routeDelete, async (context) => {
const { user } = context.get("auth");
const { id } = context.req.valid("param");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
await db
.delete(Filters)
.where(and(eq(Filters.userId, user.id), eq(Filters.id, id)));
return context.newResponse(null, 204);
});
});

View file

@ -1,8 +1,9 @@
import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api";
import { zValidator } from "@hono/zod-validator";
import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { z } from "zod";
import { db } from "~/drizzle/db";
import { FilterKeywords, Filters, RolePermissions } from "~/drizzle/schema";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
allowedMethods: ["GET", "POST"],
route: "/api/v2/filters",
@ -19,178 +20,228 @@ export const meta = applyConfig({
});
export const schemas = {
json: z
.object({
title: z.string().trim().min(1).max(100).optional(),
context: z
.array(
z.enum([
"home",
"notifications",
"public",
"thread",
"account",
]),
)
.optional(),
filter_action: z.enum(["warn", "hide"]).optional().default("warn"),
expires_in: z.coerce
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional(),
keywords_attributes: z
.array(
z.object({
keyword: z.string().trim().min(1).max(100),
whole_word: z
.string()
.transform((v) =>
["true", "1", "on"].includes(v.toLowerCase()),
)
.optional(),
}),
)
.optional(),
})
.optional(),
json: z.object({
title: z.string().trim().min(1).max(100),
context: z
.array(
z.enum([
"home",
"notifications",
"public",
"thread",
"account",
]),
)
.min(1),
filter_action: z.enum(["warn", "hide"]).optional().default("warn"),
expires_in: z.coerce
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional(),
keywords_attributes: z
.array(
z.object({
keyword: z.string().trim().min(1).max(100),
whole_word: z
.string()
.transform((v) =>
["true", "1", "on"].includes(v.toLowerCase()),
)
.optional(),
}),
)
.optional(),
}),
};
export default apiRoute((app) =>
app.on(
meta.allowedMethods,
meta.route,
jsonOrForm(),
zValidator("json", schemas.json, handleZodError),
auth(meta.auth, meta.permissions),
async (context) => {
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
switch (context.req.method) {
case "GET": {
const userFilters = await db.query.Filters.findMany({
where: (filter, { eq }) => eq(filter.userId, user.id),
with: {
keywords: true,
},
});
return context.json(
userFilters.map((filter) => ({
id: filter.id,
title: filter.title,
context: filter.context,
expires_at: filter.expireAt
? new Date(
Date.now() + filter.expireAt,
).toISOString()
: null,
filter_action: filter.filterAction,
keywords: filter.keywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
})),
);
}
case "POST": {
const form = context.req.valid("json");
if (!form) {
return context.json(
{ error: "Missing required fields" },
422,
);
}
const {
title,
context: ctx,
filter_action,
expires_in,
keywords_attributes,
} = form;
if (!title || ctx?.length === 0) {
return context.json(
{
error: "Missing required fields (title and context)",
},
422,
);
}
const newFilter = (
await db
.insert(Filters)
.values({
title: title ?? "",
context: ctx ?? [],
filterAction: filter_action,
expireAt: new Date(
Date.now() + (expires_in ?? 0),
).toISOString(),
userId: user.id,
})
.returning()
)[0];
if (!newFilter) {
return context.json(
{ error: "Failed to create filter" },
500,
);
}
const insertedKeywords =
keywords_attributes && keywords_attributes.length > 0
? await db
.insert(FilterKeywords)
.values(
keywords_attributes?.map((keyword) => ({
filterId: newFilter.id,
keyword: keyword.keyword,
wholeWord:
keyword.whole_word ?? false,
})) ?? [],
)
.returning()
: [];
return context.json({
id: newFilter.id,
title: newFilter.title,
context: newFilter.context,
expires_at: expires_in
? new Date(Date.now() + expires_in).toISOString()
: null,
filter_action: newFilter.filterAction,
keywords: insertedKeywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
} as {
id: string;
title: string;
context: string[];
expires_at: string;
filter_action: "warn" | "hide";
keywords: {
id: string;
keyword: string;
whole_word: boolean;
}[];
statuses: [];
});
}
}
},
const filterSchema = z.object({
id: z.string(),
title: z.string(),
context: z.array(z.string()),
expires_at: z.string().nullable(),
filter_action: z.enum(["warn", "hide"]),
keywords: z.array(
z.object({
id: z.string(),
keyword: z.string(),
whole_word: z.boolean(),
}),
),
);
statuses: z.array(z.string()),
});
const routeGet = createRoute({
method: "get",
path: "/api/v2/filters",
summary: "Get filters",
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
responses: {
200: {
description: "Filters",
content: {
"application/json": {
schema: z.array(filterSchema),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
const routePost = createRoute({
method: "post",
path: "/api/v2/filters",
summary: "Create filter",
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
request: {
body: {
content: {
"application/json": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Filter created",
content: {
"application/json": {
schema: filterSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) => {
app.openapi(routeGet, async (context) => {
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const userFilters = await db.query.Filters.findMany({
where: (filter, { eq }) => eq(filter.userId, user.id),
with: {
keywords: true,
},
});
return context.json(
userFilters.map((filter) => ({
id: filter.id,
title: filter.title,
context: filter.context,
expires_at: filter.expireAt
? new Date(Date.now() + filter.expireAt).toISOString()
: null,
filter_action: filter.filterAction,
keywords: filter.keywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
})),
200,
);
});
app.openapi(routePost, async (context) => {
const { user } = context.get("auth");
const {
title,
context: ctx,
filter_action,
expires_in,
keywords_attributes,
} = context.req.valid("json");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const newFilter = (
await db
.insert(Filters)
.values({
title: title ?? "",
context: ctx ?? [],
filterAction: filter_action,
expireAt: new Date(
Date.now() + (expires_in ?? 0),
).toISOString(),
userId: user.id,
})
.returning()
)[0];
if (!newFilter) {
throw new Error("Failed to create filter");
}
const insertedKeywords =
keywords_attributes && keywords_attributes.length > 0
? await db
.insert(FilterKeywords)
.values(
keywords_attributes?.map((keyword) => ({
filterId: newFilter.id,
keyword: keyword.keyword,
wholeWord: keyword.whole_word ?? false,
})) ?? [],
)
.returning()
: [];
return context.json(
{
id: newFilter.id,
title: newFilter.title,
context: newFilter.context,
expires_at: expires_in
? new Date(Date.now() + expires_in).toISOString()
: null,
filter_action: newFilter.filterAction,
keywords: insertedKeywords.map((keyword) => ({
id: keyword.id,
keyword: keyword.keyword,
whole_word: keyword.wholeWord,
})),
statuses: [],
} as {
id: string;
title: string;
context: string[];
expires_at: string;
filter_action: "warn" | "hide";
keywords: {
id: string;
keyword: string;
whole_word: boolean;
}[];
statuses: [];
},
200,
);
});
});

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig } from "@/api";
import { proxyUrl } from "@/response";
import type { Instance as ApiInstance } from "@versia/client/types";
import { createRoute, z } from "@hono/zod-openapi";
import { and, eq, isNull } from "drizzle-orm";
import { Users } from "~/drizzle/schema";
import manifest from "~/package.json";
@ -19,8 +19,110 @@ export const meta = applyConfig({
},
});
const route = createRoute({
method: "get",
path: "/api/v2/instance",
summary: "Get instance metadata",
responses: {
200: {
description: "Instance metadata",
content: {
"application/json": {
schema: z.object({
domain: z.string(),
title: z.string(),
version: z.string(),
versia_version: z.string(),
source_url: z.string(),
description: z.string(),
usage: z.object({
users: z.object({
active_month: z.number(),
}),
}),
thumbnail: z.object({
url: z.string().nullable(),
}),
banner: z.object({
url: z.string().nullable(),
}),
languages: z.array(z.string()),
configuration: z.object({
urls: z.object({
streaming: z.string().nullable(),
status: z.string().nullable(),
}),
accounts: z.object({
max_featured_tags: z.number(),
max_displayname_characters: z.number(),
avatar_size_limit: z.number(),
header_size_limit: z.number(),
max_fields_name_characters: z.number(),
max_fields_value_characters: z.number(),
max_fields: z.number(),
max_username_characters: z.number(),
max_note_characters: z.number(),
}),
statuses: z.object({
max_characters: z.number(),
max_media_attachments: z.number(),
characters_reserved_per_url: z.number(),
}),
media_attachments: z.object({
supported_mime_types: z.array(z.string()),
image_size_limit: z.number(),
image_matrix_limit: z.number(),
video_size_limit: z.number(),
video_frame_rate_limit: z.number(),
video_matrix_limit: z.number(),
max_description_characters: z.number(),
}),
polls: z.object({
max_characters_per_option: z.number(),
max_expiration: z.number(),
max_options: z.number(),
min_expiration: z.number(),
}),
translation: z.object({
enabled: z.boolean(),
}),
}),
registrations: z.object({
enabled: z.boolean(),
approval_required: z.boolean(),
message: z.string().nullable(),
url: z.string().nullable(),
}),
contact: z.object({
email: z.string().nullable(),
account: User.schema.nullable(),
}),
rules: z.array(
z.object({
id: z.string(),
text: z.string(),
hint: z.string(),
}),
),
sso: z.object({
forced: z.boolean(),
providers: z.array(
z.object({
name: z.string(),
icon: z.string(),
id: z.string(),
}),
),
}),
}),
},
},
},
},
});
export default apiRoute((app) =>
app.on(meta.allowedMethods, meta.route, async (context) => {
app.openapi(route, async (context) => {
// Get software version from package.json
const version = manifest.version;
@ -122,6 +224,6 @@ export default apiRoute((app) =>
id: p.id,
})),
},
} satisfies ApiInstance);
});
}),
);

View file

@ -1,11 +1,12 @@
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
import { zValidator } from "@hono/zod-validator";
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import sharp from "sharp";
import { z } from "zod";
import { MediaManager } from "~/classes/media/media-manager";
import { RolePermissions } from "~/drizzle/schema";
import { config } from "~/packages/config-manager/index";
import { Attachment } from "~/packages/database-interface/attachment";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -35,78 +36,104 @@ export const schemas = {
}),
};
const route = createRoute({
method: "post",
path: "/api/v2/media",
summary: "Upload media",
middleware: [auth(meta.auth, meta.permissions)],
request: {
body: {
content: {
"multipart/form-data": {
schema: schemas.form,
},
},
},
},
responses: {
200: {
description: "Uploaded media",
content: {
"application/json": {
schema: Attachment.schema,
},
},
},
413: {
description: "Payload too large",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
415: {
description: "Unsupported media type",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.on(
meta.allowedMethods,
meta.route,
zValidator("form", schemas.form, handleZodError),
auth(meta.auth, meta.permissions),
async (context) => {
const { file, thumbnail, description } = context.req.valid("form");
if (file.size > config.validation.max_media_size) {
return context.json(
{
error: `File too large, max size is ${config.validation.max_media_size} bytes`,
},
413,
);
}
if (
config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type)
) {
return context.json({ error: "Invalid file type" }, 415);
}
const sha256 = new Bun.SHA256();
const isImage = file.type.startsWith("image/");
const metadata = isImage
? await sharp(await file.arrayBuffer()).metadata()
: null;
const mediaManager = new MediaManager(config);
const { path, blurhash } = await mediaManager.addFile(file);
const url = Attachment.getUrl(path);
let thumbnailUrl = "";
if (thumbnail) {
const { path } = await mediaManager.addFile(thumbnail);
thumbnailUrl = Attachment.getUrl(path);
}
const newAttachment = await Attachment.insert({
url,
thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mimeType: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
});
// TODO: Add job to process videos and other media
if (isImage) {
return context.json(newAttachment.toApi());
}
app.openapi(route, async (context) => {
const { file, thumbnail, description } = context.req.valid("form");
if (file.size > config.validation.max_media_size) {
return context.json(
{
...newAttachment.toApi(),
url: null,
error: `File too large, max size is ${config.validation.max_media_size} bytes`,
},
202,
413,
);
},
),
}
if (
config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type)
) {
return context.json({ error: "Invalid file type" }, 415);
}
const sha256 = new Bun.SHA256();
const isImage = file.type.startsWith("image/");
const metadata = isImage
? await sharp(await file.arrayBuffer()).metadata()
: null;
const mediaManager = new MediaManager(config);
const { path, blurhash } = await mediaManager.addFile(file);
const url = Attachment.getUrl(path);
let thumbnailUrl = "";
if (thumbnail) {
const { path } = await mediaManager.addFile(thumbnail);
thumbnailUrl = Attachment.getUrl(path);
}
const newAttachment = await Attachment.insert({
url,
thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mimeType: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
});
// TODO: Add job to process videos and other media
return context.json(newAttachment.toApi(), 200);
}),
);

View file

@ -2,11 +2,10 @@ import {
apiRoute,
applyConfig,
auth,
handleZodError,
parseUserAddress,
userAddressValidator,
} from "@/api";
import { zValidator } from "@hono/zod-validator";
import { createRoute } from "@hono/zod-openapi";
import { and, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import { searchManager } from "~/classes/search/search-manager";
@ -15,6 +14,7 @@ import { Instances, Notes, RolePermissions, Users } from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
import { Note } from "~/packages/database-interface/note";
import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -38,7 +38,7 @@ export const meta = applyConfig({
export const schemas = {
query: z.object({
q: z.string().trim().optional(),
q: z.string().trim(),
type: z.string().optional(),
resolve: z.coerce.boolean().optional(),
following: z.coerce.boolean().optional(),
@ -50,173 +50,204 @@ export const schemas = {
}),
};
const route = createRoute({
method: "get",
path: "/api/v2/search",
summary: "Instance database search",
middleware: [auth(meta.auth, meta.permissions)],
request: {
query: schemas.query,
},
responses: {
200: {
description: "Search results",
content: {
"application/json": {
schema: z.object({
accounts: z.array(User.schema),
statuses: z.array(Note.schema),
hashtags: z.array(z.string()),
}),
},
},
},
401: {
description:
"Cannot use resolve or offset without being authenticated",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
501: {
description: "Search is not enabled on this server",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.on(
meta.allowedMethods,
meta.route,
zValidator("query", schemas.query, handleZodError),
auth(meta.auth, meta.permissions),
async (context) => {
const { user: self } = context.get("auth");
const { q, type, resolve, following, account_id, limit, offset } =
context.req.valid("query");
app.openapi(route, async (context) => {
const { user: self } = context.get("auth");
const { q, type, resolve, following, account_id, limit, offset } =
context.req.valid("query");
if (!self && (resolve || offset)) {
return context.json(
{
error: "Cannot use resolve or offset without being authenticated",
},
401,
if (!self && (resolve || offset)) {
return context.json(
{
error: "Cannot use resolve or offset without being authenticated",
},
401,
);
}
if (!config.sonic.enabled) {
return context.json(
{ error: "Search is not enabled on this server" },
501,
);
}
let accountResults: string[] = [];
let statusResults: string[] = [];
if (!type || type === "accounts") {
// Check if q is matching format username@domain.com or @username@domain.com
const accountMatches = q?.trim().match(userAddressValidator);
if (accountMatches) {
// Remove leading @ if it exists
if (accountMatches[0].startsWith("@")) {
accountMatches[0] = accountMatches[0].slice(1);
}
const { username, domain } = parseUserAddress(
accountMatches[0],
);
}
if (!q) {
return context.json({ error: "Query is required" }, 400);
}
const accountId = (
await db
.select({
id: Users.id,
})
.from(Users)
.leftJoin(Instances, eq(Users.instanceId, Instances.id))
.where(
and(
eq(Users.username, username),
eq(Instances.baseUrl, domain),
),
)
)[0]?.id;
if (!config.sonic.enabled) {
return context.json(
{ error: "Search is not enabled on this server" },
501,
);
}
const account = accountId ? await User.fromId(accountId) : null;
let accountResults: string[] = [];
let statusResults: string[] = [];
if (!type || type === "accounts") {
// Check if q is matching format username@domain.com or @username@domain.com
const accountMatches = q?.trim().match(userAddressValidator);
if (accountMatches) {
// Remove leading @ if it exists
if (accountMatches[0].startsWith("@")) {
accountMatches[0] = accountMatches[0].slice(1);
}
const { username, domain } = parseUserAddress(
accountMatches[0],
);
const accountId = (
await db
.select({
id: Users.id,
})
.from(Users)
.leftJoin(
Instances,
eq(Users.instanceId, Instances.id),
)
.where(
and(
eq(Users.username, username),
eq(Instances.baseUrl, domain),
),
)
)[0]?.id;
const account = accountId
? await User.fromId(accountId)
: null;
if (account) {
return context.json({
if (account) {
return context.json(
{
accounts: [account.toApi()],
statuses: [],
hashtags: [],
});
}
},
200,
);
}
if (resolve) {
const manager = await (
self ?? User
).getFederationRequester();
if (resolve) {
const manager = await (
self ?? User
).getFederationRequester();
const uri = await User.webFinger(
manager,
username,
domain,
);
const uri = await User.webFinger(manager, username, domain);
const newUser = await User.resolve(uri);
const newUser = await User.resolve(uri);
if (newUser) {
return context.json({
if (newUser) {
return context.json(
{
accounts: [newUser.toApi()],
statuses: [],
hashtags: [],
});
}
},
200,
);
}
}
accountResults = await searchManager.searchAccounts(
q,
Number(limit) || 10,
Number(offset) || 0,
);
}
if (!type || type === "statuses") {
statusResults = await searchManager.searchStatuses(
q,
Number(limit) || 10,
Number(offset) || 0,
);
}
accountResults = await searchManager.searchAccounts(
q,
Number(limit) || 10,
Number(offset) || 0,
);
}
const accounts =
accountResults.length > 0
? await User.manyFromSql(
and(
inArray(
Users.id,
accountResults.map((hit) => hit),
),
self && following
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${
self?.id
} AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${
Users.id
})`
: undefined,
if (!type || type === "statuses") {
statusResults = await searchManager.searchStatuses(
q,
Number(limit) || 10,
Number(offset) || 0,
);
}
const accounts =
accountResults.length > 0
? await User.manyFromSql(
and(
inArray(
Users.id,
accountResults.map((hit) => hit),
),
)
: [];
self && following
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${
self?.id
} AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${
Users.id
})`
: undefined,
),
)
: [];
const statuses =
statusResults.length > 0
? await Note.manyFromSql(
and(
inArray(
Notes.id,
statusResults.map((hit) => hit),
),
account_id
? eq(Notes.authorId, account_id)
: undefined,
self && following
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${
self?.id
} AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${
Notes.authorId
})`
: undefined,
const statuses =
statusResults.length > 0
? await Note.manyFromSql(
and(
inArray(
Notes.id,
statusResults.map((hit) => hit),
),
undefined,
undefined,
undefined,
self?.id,
)
: [];
account_id
? eq(Notes.authorId, account_id)
: undefined,
self && following
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${
self?.id
} AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${
Notes.authorId
})`
: undefined,
),
undefined,
undefined,
undefined,
self?.id,
)
: [];
return context.json({
return context.json(
{
accounts: accounts.map((account) => account.toApi()),
statuses: await Promise.all(
statuses.map((status) => status.toApi(self)),
),
hashtags: [],
});
},
),
},
200,
);
}),
);