mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 22:09:16 +01:00
refactor(api): ♻️ More OpenAPI refactoring work
This commit is contained in:
parent
6d9e385a04
commit
5aa1c4e625
35 changed files with 4883 additions and 1815 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,6 @@ describe(meta.route, () => {
|
|||
},
|
||||
);
|
||||
|
||||
expect(filterDeleteResponse.status).toBe(200);
|
||||
expect(filterDeleteResponse.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ describe(meta.route, () => {
|
|||
},
|
||||
);
|
||||
|
||||
expect(filterDeleteResponse.status).toBe(200);
|
||||
expect(filterDeleteResponse.status).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -234,6 +234,6 @@ describe(meta.route, () => {
|
|||
},
|
||||
);
|
||||
|
||||
expect(filterDeleteResponse.status).toBe(200);
|
||||
expect(filterDeleteResponse.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue