mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(api): ♻️ More OpenAPI refactoring work
This commit is contained in:
parent
6d9e385a04
commit
5aa1c4e625
|
|
@ -1,13 +1,6 @@
|
||||||
import {
|
import { apiRoute, applyConfig, auth, emojiValidator, jsonOrForm } from "@/api";
|
||||||
apiRoute,
|
|
||||||
applyConfig,
|
|
||||||
auth,
|
|
||||||
emojiValidator,
|
|
||||||
handleZodError,
|
|
||||||
jsonOrForm,
|
|
||||||
} from "@/api";
|
|
||||||
import { mimeLookup } from "@/content_types";
|
import { mimeLookup } from "@/content_types";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { MediaManager } from "~/classes/media/media-manager";
|
import { MediaManager } from "~/classes/media/media-manager";
|
||||||
|
|
@ -16,6 +9,7 @@ import { Emojis, RolePermissions } from "~/drizzle/schema";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
import { Attachment } from "~/packages/database-interface/attachment";
|
import { Attachment } from "~/packages/database-interface/attachment";
|
||||||
import { Emoji } from "~/packages/database-interface/emoji";
|
import { Emoji } from "~/packages/database-interface/emoji";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["DELETE", "GET", "PATCH"],
|
allowedMethods: ["DELETE", "GET", "PATCH"],
|
||||||
|
|
@ -62,146 +56,302 @@ export const schemas = {
|
||||||
.or(z.boolean())
|
.or(z.boolean())
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.partial()
|
.partial(),
|
||||||
.optional(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
const routeGet = createRoute({
|
||||||
app.on(
|
method: "get",
|
||||||
meta.allowedMethods,
|
path: "/api/v1/emojis/{id}",
|
||||||
meta.route,
|
summary: "Get emoji data",
|
||||||
jsonOrForm(),
|
middleware: [auth(meta.auth, meta.permissions)],
|
||||||
zValidator("param", schemas.param, handleZodError),
|
request: {
|
||||||
zValidator("json", schemas.json, handleZodError),
|
params: schemas.param,
|
||||||
auth(meta.auth, meta.permissions),
|
},
|
||||||
async (context) => {
|
responses: {
|
||||||
const { id } = context.req.valid("param");
|
200: {
|
||||||
const { user } = context.get("auth");
|
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) {
|
const routePatch = createRoute({
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
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) {
|
export default apiRoute((app) => {
|
||||||
return context.json({ error: "Emoji not found" }, 404);
|
app.openapi(routeGet, async (context) => {
|
||||||
}
|
const { id } = context.req.valid("param");
|
||||||
|
const { user } = context.get("auth");
|
||||||
|
|
||||||
// Check if user is admin
|
if (!user) {
|
||||||
if (
|
return context.json({ error: "Unauthorized" }, 401);
|
||||||
!user.hasPermission(RolePermissions.ManageEmojis) &&
|
}
|
||||||
emoji.data.ownerId !== user.data.id
|
|
||||||
) {
|
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(
|
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) {
|
if (element instanceof File) {
|
||||||
case "DELETE": {
|
const uploaded = await mediaManager.addFile(element);
|
||||||
await mediaManager.deleteFileByUrl(emoji.data.url);
|
|
||||||
|
|
||||||
await db.delete(Emojis).where(eq(Emojis.id, id));
|
url = uploaded.path;
|
||||||
|
contentType = uploaded.uploadedFile.type;
|
||||||
return context.newResponse(null, 204);
|
} else {
|
||||||
}
|
url = element;
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
),
|
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 { renderMarkdownInPath } from "@/markdown";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -14,21 +15,38 @@ export const meta = applyConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
const route = createRoute({
|
||||||
app.on(
|
method: "get",
|
||||||
meta.allowedMethods,
|
path: "/api/v1/instance/extended_description",
|
||||||
meta.route,
|
summary: "Get extended description",
|
||||||
auth(meta.auth, meta.permissions),
|
responses: {
|
||||||
async (context) => {
|
200: {
|
||||||
const { content, lastModified } = await renderMarkdownInPath(
|
description: "Extended description",
|
||||||
config.instance.extended_description_path ?? "",
|
content: {
|
||||||
"This is a [Versia](https://versia.pub) server with the default extended description.",
|
"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(),
|
updated_at: lastModified.toISOString(),
|
||||||
content,
|
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
|
// Try to GET the filter again
|
||||||
const getResponse = await fakeRequest(
|
const getResponse = await fakeRequest(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api";
|
import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~/drizzle/db";
|
import { db } from "~/drizzle/db";
|
||||||
import { FilterKeywords, Filters, RolePermissions } from "~/drizzle/schema";
|
import { FilterKeywords, Filters, RolePermissions } from "~/drizzle/schema";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET", "PUT", "DELETE"],
|
allowedMethods: ["GET", "PUT", "DELETE"],
|
||||||
|
|
@ -68,159 +69,281 @@ export const schemas = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
const filterSchema = z.object({
|
||||||
app.on(
|
id: z.string(),
|
||||||
meta.allowedMethods,
|
title: z.string(),
|
||||||
meta.route,
|
context: z.array(z.string()),
|
||||||
jsonOrForm(),
|
expires_at: z.string().nullable(),
|
||||||
zValidator("param", schemas.param, handleZodError),
|
filter_action: z.enum(["warn", "hide"]),
|
||||||
zValidator("json", schemas.json, handleZodError),
|
keywords: z.array(
|
||||||
auth(meta.auth, meta.permissions),
|
z.object({
|
||||||
async (context) => {
|
id: z.string(),
|
||||||
const { user } = context.get("auth");
|
keyword: z.string(),
|
||||||
const { id } = context.req.valid("param");
|
whole_word: z.boolean(),
|
||||||
|
}),
|
||||||
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({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
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 { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~/drizzle/db";
|
import { db } from "~/drizzle/db";
|
||||||
import { FilterKeywords, Filters, RolePermissions } from "~/drizzle/schema";
|
import { FilterKeywords, Filters, RolePermissions } from "~/drizzle/schema";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET", "POST"],
|
allowedMethods: ["GET", "POST"],
|
||||||
route: "/api/v2/filters",
|
route: "/api/v2/filters",
|
||||||
|
|
@ -19,178 +20,228 @@ export const meta = applyConfig({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
json: z
|
json: z.object({
|
||||||
.object({
|
title: z.string().trim().min(1).max(100),
|
||||||
title: z.string().trim().min(1).max(100).optional(),
|
context: z
|
||||||
context: z
|
.array(
|
||||||
.array(
|
z.enum([
|
||||||
z.enum([
|
"home",
|
||||||
"home",
|
"notifications",
|
||||||
"notifications",
|
"public",
|
||||||
"public",
|
"thread",
|
||||||
"thread",
|
"account",
|
||||||
"account",
|
]),
|
||||||
]),
|
)
|
||||||
)
|
.min(1),
|
||||||
.optional(),
|
filter_action: z.enum(["warn", "hide"]).optional().default("warn"),
|
||||||
filter_action: z.enum(["warn", "hide"]).optional().default("warn"),
|
expires_in: z.coerce
|
||||||
expires_in: z.coerce
|
.number()
|
||||||
.number()
|
.int()
|
||||||
.int()
|
.min(60)
|
||||||
.min(60)
|
.max(60 * 60 * 24 * 365 * 5)
|
||||||
.max(60 * 60 * 24 * 365 * 5)
|
.optional(),
|
||||||
.optional(),
|
keywords_attributes: z
|
||||||
keywords_attributes: z
|
.array(
|
||||||
.array(
|
z.object({
|
||||||
z.object({
|
keyword: z.string().trim().min(1).max(100),
|
||||||
keyword: z.string().trim().min(1).max(100),
|
whole_word: z
|
||||||
whole_word: z
|
.string()
|
||||||
.string()
|
.transform((v) =>
|
||||||
.transform((v) =>
|
["true", "1", "on"].includes(v.toLowerCase()),
|
||||||
["true", "1", "on"].includes(v.toLowerCase()),
|
)
|
||||||
)
|
.optional(),
|
||||||
.optional(),
|
}),
|
||||||
}),
|
)
|
||||||
)
|
.optional(),
|
||||||
.optional(),
|
}),
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
const filterSchema = z.object({
|
||||||
app.on(
|
id: z.string(),
|
||||||
meta.allowedMethods,
|
title: z.string(),
|
||||||
meta.route,
|
context: z.array(z.string()),
|
||||||
jsonOrForm(),
|
expires_at: z.string().nullable(),
|
||||||
zValidator("json", schemas.json, handleZodError),
|
filter_action: z.enum(["warn", "hide"]),
|
||||||
auth(meta.auth, meta.permissions),
|
keywords: z.array(
|
||||||
async (context) => {
|
z.object({
|
||||||
const { user } = context.get("auth");
|
id: z.string(),
|
||||||
|
keyword: z.string(),
|
||||||
if (!user) {
|
whole_word: z.boolean(),
|
||||||
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: [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
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 { apiRoute, applyConfig } from "@/api";
|
||||||
import { proxyUrl } from "@/response";
|
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 { and, eq, isNull } from "drizzle-orm";
|
||||||
import { Users } from "~/drizzle/schema";
|
import { Users } from "~/drizzle/schema";
|
||||||
import manifest from "~/package.json";
|
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) =>
|
export default apiRoute((app) =>
|
||||||
app.on(meta.allowedMethods, meta.route, async (context) => {
|
app.openapi(route, async (context) => {
|
||||||
// Get software version from package.json
|
// Get software version from package.json
|
||||||
const version = manifest.version;
|
const version = manifest.version;
|
||||||
|
|
||||||
|
|
@ -122,6 +224,6 @@ export default apiRoute((app) =>
|
||||||
id: p.id,
|
id: p.id,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
} satisfies ApiInstance);
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
import { apiRoute, applyConfig, auth } from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { MediaManager } from "~/classes/media/media-manager";
|
import { MediaManager } from "~/classes/media/media-manager";
|
||||||
import { RolePermissions } from "~/drizzle/schema";
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
import { config } from "~/packages/config-manager/index";
|
import { config } from "~/packages/config-manager/index";
|
||||||
import { Attachment } from "~/packages/database-interface/attachment";
|
import { Attachment } from "~/packages/database-interface/attachment";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
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) =>
|
export default apiRoute((app) =>
|
||||||
app.on(
|
app.openapi(route, async (context) => {
|
||||||
meta.allowedMethods,
|
const { file, thumbnail, description } = context.req.valid("form");
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (file.size > config.validation.max_media_size) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
...newAttachment.toApi(),
|
error: `File too large, max size is ${config.validation.max_media_size} bytes`,
|
||||||
url: null,
|
|
||||||
},
|
},
|
||||||
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,
|
apiRoute,
|
||||||
applyConfig,
|
applyConfig,
|
||||||
auth,
|
auth,
|
||||||
handleZodError,
|
|
||||||
parseUserAddress,
|
parseUserAddress,
|
||||||
userAddressValidator,
|
userAddressValidator,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { searchManager } from "~/classes/search/search-manager";
|
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 { config } from "~/packages/config-manager";
|
||||||
import { Note } from "~/packages/database-interface/note";
|
import { Note } from "~/packages/database-interface/note";
|
||||||
import { User } from "~/packages/database-interface/user";
|
import { User } from "~/packages/database-interface/user";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -38,7 +38,7 @@ export const meta = applyConfig({
|
||||||
|
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
query: z.object({
|
query: z.object({
|
||||||
q: z.string().trim().optional(),
|
q: z.string().trim(),
|
||||||
type: z.string().optional(),
|
type: z.string().optional(),
|
||||||
resolve: z.coerce.boolean().optional(),
|
resolve: z.coerce.boolean().optional(),
|
||||||
following: 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) =>
|
export default apiRoute((app) =>
|
||||||
app.on(
|
app.openapi(route, async (context) => {
|
||||||
meta.allowedMethods,
|
const { user: self } = context.get("auth");
|
||||||
meta.route,
|
const { q, type, resolve, following, account_id, limit, offset } =
|
||||||
zValidator("query", schemas.query, handleZodError),
|
context.req.valid("query");
|
||||||
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");
|
|
||||||
|
|
||||||
if (!self && (resolve || offset)) {
|
if (!self && (resolve || offset)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "Cannot use resolve or offset without being authenticated",
|
error: "Cannot use resolve or offset without being authenticated",
|
||||||
},
|
},
|
||||||
401,
|
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) {
|
const accountId = (
|
||||||
return context.json({ error: "Query is required" }, 400);
|
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) {
|
const account = accountId ? await User.fromId(accountId) : null;
|
||||||
return context.json(
|
|
||||||
{ error: "Search is not enabled on this server" },
|
|
||||||
501,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let accountResults: string[] = [];
|
if (account) {
|
||||||
let statusResults: string[] = [];
|
return context.json(
|
||||||
|
{
|
||||||
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({
|
|
||||||
accounts: [account.toApi()],
|
accounts: [account.toApi()],
|
||||||
statuses: [],
|
statuses: [],
|
||||||
hashtags: [],
|
hashtags: [],
|
||||||
});
|
},
|
||||||
}
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (resolve) {
|
if (resolve) {
|
||||||
const manager = await (
|
const manager = await (
|
||||||
self ?? User
|
self ?? User
|
||||||
).getFederationRequester();
|
).getFederationRequester();
|
||||||
|
|
||||||
const uri = await User.webFinger(
|
const uri = await User.webFinger(manager, username, domain);
|
||||||
manager,
|
|
||||||
username,
|
|
||||||
domain,
|
|
||||||
);
|
|
||||||
|
|
||||||
const newUser = await User.resolve(uri);
|
const newUser = await User.resolve(uri);
|
||||||
|
|
||||||
if (newUser) {
|
if (newUser) {
|
||||||
return context.json({
|
return context.json(
|
||||||
|
{
|
||||||
accounts: [newUser.toApi()],
|
accounts: [newUser.toApi()],
|
||||||
statuses: [],
|
statuses: [],
|
||||||
hashtags: [],
|
hashtags: [],
|
||||||
});
|
},
|
||||||
}
|
200,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
accountResults = await searchManager.searchAccounts(
|
|
||||||
q,
|
|
||||||
Number(limit) || 10,
|
|
||||||
Number(offset) || 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!type || type === "statuses") {
|
accountResults = await searchManager.searchAccounts(
|
||||||
statusResults = await searchManager.searchStatuses(
|
q,
|
||||||
q,
|
Number(limit) || 10,
|
||||||
Number(limit) || 10,
|
Number(offset) || 0,
|
||||||
Number(offset) || 0,
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const accounts =
|
if (!type || type === "statuses") {
|
||||||
accountResults.length > 0
|
statusResults = await searchManager.searchStatuses(
|
||||||
? await User.manyFromSql(
|
q,
|
||||||
and(
|
Number(limit) || 10,
|
||||||
inArray(
|
Number(offset) || 0,
|
||||||
Users.id,
|
);
|
||||||
accountResults.map((hit) => hit),
|
}
|
||||||
),
|
|
||||||
self && following
|
const accounts =
|
||||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${
|
accountResults.length > 0
|
||||||
self?.id
|
? await User.manyFromSql(
|
||||||
} AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${
|
and(
|
||||||
Users.id
|
inArray(
|
||||||
})`
|
Users.id,
|
||||||
: undefined,
|
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 =
|
const statuses =
|
||||||
statusResults.length > 0
|
statusResults.length > 0
|
||||||
? await Note.manyFromSql(
|
? await Note.manyFromSql(
|
||||||
and(
|
and(
|
||||||
inArray(
|
inArray(
|
||||||
Notes.id,
|
Notes.id,
|
||||||
statusResults.map((hit) => hit),
|
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,
|
|
||||||
),
|
),
|
||||||
undefined,
|
account_id
|
||||||
undefined,
|
? eq(Notes.authorId, account_id)
|
||||||
undefined,
|
: undefined,
|
||||||
self?.id,
|
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()),
|
accounts: accounts.map((account) => account.toApi()),
|
||||||
statuses: await Promise.all(
|
statuses: await Promise.all(
|
||||||
statuses.map((status) => status.toApi(self)),
|
statuses.map((status) => status.toApi(self)),
|
||||||
),
|
),
|
||||||
hashtags: [],
|
hashtags: [],
|
||||||
});
|
},
|
||||||
},
|
200,
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -24,40 +25,63 @@ export const schemas = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
const route = createRoute({
|
||||||
app.on(
|
method: "get",
|
||||||
meta.allowedMethods,
|
path: "/media/{hash}/{name}",
|
||||||
meta.route,
|
summary: "Get media file by hash and name",
|
||||||
zValidator("param", schemas.param, handleZodError),
|
request: {
|
||||||
zValidator("header", schemas.header, handleZodError),
|
params: schemas.param,
|
||||||
async (context) => {
|
headers: schemas.header,
|
||||||
const { hash, name } = context.req.valid("param");
|
},
|
||||||
const { range } = context.req.valid("header");
|
responses: {
|
||||||
|
200: {
|
||||||
// parse `Range` header
|
description: "Media",
|
||||||
const [start = 0, end = Number.POSITIVE_INFINITY] = (
|
content: {
|
||||||
range
|
"*": {
|
||||||
.split("=") // ["Range: bytes", "0-100"]
|
schema: z.any(),
|
||||||
.at(-1) || ""
|
},
|
||||||
) // "0-100"
|
},
|
||||||
.split("-") // ["0", "100"]
|
|
||||||
.map(Number); // [0, 100]
|
|
||||||
|
|
||||||
// Serve file from filesystem
|
|
||||||
const file = Bun.file(`./uploads/${hash}/${name}`);
|
|
||||||
|
|
||||||
const buffer = await file.arrayBuffer();
|
|
||||||
|
|
||||||
if (!(await file.exists())) {
|
|
||||||
return context.json({ error: "File not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Can't directly copy file into Response because this crashes Bun for now
|
|
||||||
return context.newResponse(buffer, 200, {
|
|
||||||
"Content-Type": file.type || "application/octet-stream",
|
|
||||||
"Content-Length": `${file.size - start}`,
|
|
||||||
"Content-Range": `bytes ${start}-${end}/${file.size}`,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
404: {
|
||||||
|
description: "File not found",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { hash, name } = context.req.valid("param");
|
||||||
|
const { range } = context.req.valid("header");
|
||||||
|
|
||||||
|
// parse `Range` header
|
||||||
|
const [start = 0, end = Number.POSITIVE_INFINITY] = (
|
||||||
|
range
|
||||||
|
.split("=") // ["Range: bytes", "0-100"]
|
||||||
|
.at(-1) || ""
|
||||||
|
) // "0-100"
|
||||||
|
.split("-") // ["0", "100"]
|
||||||
|
.map(Number); // [0, 100]
|
||||||
|
|
||||||
|
// Serve file from filesystem
|
||||||
|
const file = Bun.file(`./uploads/${hash}/${name}`);
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
return context.json({ error: "File not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't directly copy file into Response because this crashes Bun for now
|
||||||
|
return context.newResponse(buffer, 200, {
|
||||||
|
"Content-Type": file.type || "application/octet-stream",
|
||||||
|
"Content-Length": `${file.size - start}`,
|
||||||
|
"Content-Range": `bytes ${start}-${end}/${file.size}`,
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Hono doesn't type this response so this has a TS error
|
||||||
|
}) as any;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import type { StatusCode } from "hono/utils/http-status";
|
import type { StatusCode } from "hono/utils/http-status";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -24,54 +25,76 @@ export const schemas = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
const route = createRoute({
|
||||||
app.on(
|
method: "get",
|
||||||
meta.allowedMethods,
|
path: "/media/proxy/{id}",
|
||||||
meta.route,
|
summary: "Proxy media through the server",
|
||||||
zValidator("param", schemas.param, handleZodError),
|
request: {
|
||||||
async (context) => {
|
params: schemas.param,
|
||||||
const { id } = context.req.valid("param");
|
},
|
||||||
|
responses: {
|
||||||
// Check if URL is valid
|
200: {
|
||||||
if (!URL.canParse(id)) {
|
description: "Media",
|
||||||
return context.json(
|
content: {
|
||||||
{ error: "Invalid URL (it should be encoded as base64url" },
|
"*": {
|
||||||
400,
|
schema: z.any(),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const media = await fetch(id, {
|
|
||||||
headers: {
|
|
||||||
"Accept-Encoding": "br",
|
|
||||||
},
|
},
|
||||||
// @ts-expect-error Proxy is a Bun-specific feature
|
},
|
||||||
proxy: config.http.proxy.address,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if file extension ends in svg or svg
|
|
||||||
// Cloudflare R2 serves those as application/xml
|
|
||||||
if (
|
|
||||||
media.headers.get("Content-Type") === "application/xml" &&
|
|
||||||
id.endsWith(".svg")
|
|
||||||
) {
|
|
||||||
media.headers.set("Content-Type", "image/svg+xml");
|
|
||||||
}
|
|
||||||
|
|
||||||
const realFilename =
|
|
||||||
media.headers
|
|
||||||
.get("Content-Disposition")
|
|
||||||
?.match(/filename="(.+)"/)?.[1] || id.split("/").pop();
|
|
||||||
|
|
||||||
return context.newResponse(media.body, media.status as StatusCode, {
|
|
||||||
"Content-Type":
|
|
||||||
media.headers.get("Content-Type") ||
|
|
||||||
"application/octet-stream",
|
|
||||||
"Content-Length": media.headers.get("Content-Length") || "0",
|
|
||||||
"Content-Security-Policy": "",
|
|
||||||
"Content-Encoding": "",
|
|
||||||
// Real filename
|
|
||||||
"Content-Disposition": `inline; filename="${realFilename}"`,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
400: {
|
||||||
|
description: "Invalid URL to proxy",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
// Check if URL is valid
|
||||||
|
if (!URL.canParse(id)) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Invalid URL (it should be encoded as base64url" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = await fetch(id, {
|
||||||
|
headers: {
|
||||||
|
"Accept-Encoding": "br",
|
||||||
|
},
|
||||||
|
// @ts-expect-error Proxy is a Bun-specific feature
|
||||||
|
proxy: config.http.proxy.address,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if file extension ends in svg or svg
|
||||||
|
// Cloudflare R2 serves those as application/xml
|
||||||
|
if (
|
||||||
|
media.headers.get("Content-Type") === "application/xml" &&
|
||||||
|
id.endsWith(".svg")
|
||||||
|
) {
|
||||||
|
media.headers.set("Content-Type", "image/svg+xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
const realFilename =
|
||||||
|
media.headers
|
||||||
|
.get("Content-Disposition")
|
||||||
|
?.match(/filename="(.+)"/)?.[1] || id.split("/").pop();
|
||||||
|
|
||||||
|
return context.newResponse(media.body, media.status as StatusCode, {
|
||||||
|
"Content-Type":
|
||||||
|
media.headers.get("Content-Type") || "application/octet-stream",
|
||||||
|
"Content-Length": media.headers.get("Content-Length") || "0",
|
||||||
|
"Content-Security-Policy": "",
|
||||||
|
"Content-Encoding": "",
|
||||||
|
// Real filename
|
||||||
|
"Content-Disposition": `inline; filename="${realFilename}"`,
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Hono doesn't type this response so this has a TS error
|
||||||
|
}) as any;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
import { randomString } from "@/math";
|
import { randomString } from "@/math";
|
||||||
import { setCookie } from "@hono/hono/cookie";
|
import { setCookie } from "@hono/hono/cookie";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
|
|
@ -40,6 +40,24 @@ export const schemas = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/oauth/sso/{issuer}/callback",
|
||||||
|
summary: "SSO callback",
|
||||||
|
description:
|
||||||
|
"After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code",
|
||||||
|
request: {
|
||||||
|
query: schemas.query,
|
||||||
|
params: schemas.param,
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
302: {
|
||||||
|
description:
|
||||||
|
"Redirect to frontend's consent route, or redirect to login page with error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const returnError = (
|
const returnError = (
|
||||||
context: Context,
|
context: Context,
|
||||||
query: object,
|
query: object,
|
||||||
|
|
@ -63,155 +81,124 @@ const returnError = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth Callback endpoint
|
|
||||||
* After the user has authenticated to an external OpenID provider,
|
|
||||||
* they are redirected here to complete the OAuth flow and get a code
|
|
||||||
*/
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(
|
app.openapi(route, async (context) => {
|
||||||
meta.allowedMethods,
|
const currentUrl = new URL(context.req.url);
|
||||||
meta.route,
|
const redirectUrl = new URL(context.req.url);
|
||||||
zValidator("query", schemas.query, handleZodError),
|
|
||||||
zValidator("param", schemas.param, handleZodError),
|
|
||||||
async (context) => {
|
|
||||||
const currentUrl = new URL(context.req.url);
|
|
||||||
const redirectUrl = new URL(context.req.url);
|
|
||||||
|
|
||||||
// Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https
|
// Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https
|
||||||
// Looking at you, Traefik
|
// Looking at you, Traefik
|
||||||
if (
|
if (
|
||||||
new URL(config.http.base_url).protocol === "https:" &&
|
new URL(config.http.base_url).protocol === "https:" &&
|
||||||
currentUrl.protocol === "http:"
|
currentUrl.protocol === "http:"
|
||||||
) {
|
) {
|
||||||
currentUrl.protocol = "https:";
|
currentUrl.protocol = "https:";
|
||||||
redirectUrl.protocol = "https:";
|
redirectUrl.protocol = "https:";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove state query parameter from URL
|
// Remove state query parameter from URL
|
||||||
currentUrl.searchParams.delete("state");
|
currentUrl.searchParams.delete("state");
|
||||||
redirectUrl.searchParams.delete("state");
|
redirectUrl.searchParams.delete("state");
|
||||||
// Remove issuer query parameter from URL (can cause redirect URI mismatches)
|
// Remove issuer query parameter from URL (can cause redirect URI mismatches)
|
||||||
redirectUrl.searchParams.delete("iss");
|
redirectUrl.searchParams.delete("iss");
|
||||||
redirectUrl.searchParams.delete("code");
|
redirectUrl.searchParams.delete("code");
|
||||||
const { issuer: issuerParam } = context.req.valid("param");
|
const { issuer: issuerParam } = context.req.valid("param");
|
||||||
const { flow: flowId, user_id, link } = context.req.valid("query");
|
const { flow: flowId, user_id, link } = context.req.valid("query");
|
||||||
|
|
||||||
const manager = new OAuthManager(issuerParam);
|
const manager = new OAuthManager(issuerParam);
|
||||||
|
|
||||||
const userInfo = await manager.automaticOidcFlow(
|
const userInfo = await manager.automaticOidcFlow(
|
||||||
flowId,
|
flowId,
|
||||||
currentUrl,
|
currentUrl,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
(error, message, app) =>
|
(error, message, app) =>
|
||||||
returnError(
|
returnError(
|
||||||
context,
|
context,
|
||||||
manager.processOAuth2Error(app),
|
manager.processOAuth2Error(app),
|
||||||
error,
|
error,
|
||||||
message,
|
message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userInfo instanceof Response) {
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sub, email, preferred_username, picture } = userInfo.userInfo;
|
||||||
|
const flow = userInfo.flow;
|
||||||
|
|
||||||
|
// If linking account
|
||||||
|
if (link && user_id) {
|
||||||
|
return await manager.linkUser(user_id, context, userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId = (
|
||||||
|
await db.query.OpenIdAccounts.findFirst({
|
||||||
|
where: (account, { eq, and }) =>
|
||||||
|
and(
|
||||||
|
eq(account.serverId, sub),
|
||||||
|
eq(account.issuerId, manager.issuer.id),
|
||||||
),
|
),
|
||||||
);
|
})
|
||||||
|
)?.userId;
|
||||||
|
|
||||||
if (userInfo instanceof Response) {
|
if (!userId) {
|
||||||
return userInfo;
|
// Register new user
|
||||||
}
|
if (config.signups.registration && config.oidc.allow_registration) {
|
||||||
|
let username =
|
||||||
|
preferred_username ??
|
||||||
|
email?.split("@")[0] ??
|
||||||
|
randomString(8, "hex");
|
||||||
|
|
||||||
const { sub, email, preferred_username, picture } =
|
const usernameValidator = z
|
||||||
userInfo.userInfo;
|
.string()
|
||||||
const flow = userInfo.flow;
|
.regex(/^[a-z0-9_]+$/)
|
||||||
|
.min(3)
|
||||||
// If linking account
|
.max(config.validation.max_username_size)
|
||||||
if (link && user_id) {
|
.refine(
|
||||||
return await manager.linkUser(user_id, context, userInfo);
|
(value) =>
|
||||||
}
|
!config.validation.username_blacklist.includes(
|
||||||
|
value,
|
||||||
let userId = (
|
|
||||||
await db.query.OpenIdAccounts.findFirst({
|
|
||||||
where: (account, { eq, and }) =>
|
|
||||||
and(
|
|
||||||
eq(account.serverId, sub),
|
|
||||||
eq(account.issuerId, manager.issuer.id),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)?.userId;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
// Register new user
|
|
||||||
if (
|
|
||||||
config.signups.registration &&
|
|
||||||
config.oidc.allow_registration
|
|
||||||
) {
|
|
||||||
let username =
|
|
||||||
preferred_username ??
|
|
||||||
email?.split("@")[0] ??
|
|
||||||
randomString(8, "hex");
|
|
||||||
|
|
||||||
const usernameValidator = z
|
|
||||||
.string()
|
|
||||||
.regex(/^[a-z0-9_]+$/)
|
|
||||||
.min(3)
|
|
||||||
.max(config.validation.max_username_size)
|
|
||||||
.refine(
|
|
||||||
(value) =>
|
|
||||||
!config.validation.username_blacklist.includes(
|
|
||||||
value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.refine((value) =>
|
|
||||||
config.filters.username.some((filter) =>
|
|
||||||
value.match(filter),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.refine(
|
.refine((value) =>
|
||||||
async (value) =>
|
config.filters.username.some((filter) =>
|
||||||
!(await User.fromSql(
|
value.match(filter),
|
||||||
and(
|
),
|
||||||
eq(Users.username, value),
|
)
|
||||||
isNull(Users.instanceId),
|
.refine(
|
||||||
),
|
async (value) =>
|
||||||
)),
|
!(await User.fromSql(
|
||||||
);
|
and(
|
||||||
|
eq(Users.username, value),
|
||||||
try {
|
isNull(Users.instanceId),
|
||||||
await usernameValidator.parseAsync(username);
|
),
|
||||||
} catch {
|
)),
|
||||||
username = randomString(8, "hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
const doesEmailExist = email
|
|
||||||
? !!(await User.fromSql(eq(Users.email, email)))
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Create new user
|
|
||||||
const user = await User.fromDataLocal({
|
|
||||||
email: doesEmailExist ? undefined : email,
|
|
||||||
username,
|
|
||||||
avatar: picture,
|
|
||||||
password: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Link account
|
|
||||||
await manager.linkUserInDatabase(user.id, sub);
|
|
||||||
|
|
||||||
userId = user.id;
|
|
||||||
} else {
|
|
||||||
return returnError(
|
|
||||||
context,
|
|
||||||
{
|
|
||||||
redirect_uri: flow.application?.redirectUri,
|
|
||||||
client_id: flow.application?.clientId,
|
|
||||||
response_type: "code",
|
|
||||||
scope: flow.application?.scopes,
|
|
||||||
},
|
|
||||||
"invalid_request",
|
|
||||||
"No user found with that account",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await usernameValidator.parseAsync(username);
|
||||||
|
} catch {
|
||||||
|
username = randomString(8, "hex");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const user = await User.fromId(userId);
|
const doesEmailExist = email
|
||||||
|
? !!(await User.fromSql(eq(Users.email, email)))
|
||||||
|
: false;
|
||||||
|
|
||||||
if (!user) {
|
// Create new user
|
||||||
|
const user = await User.fromDataLocal({
|
||||||
|
email: doesEmailExist ? undefined : email,
|
||||||
|
username,
|
||||||
|
avatar: picture,
|
||||||
|
password: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link account
|
||||||
|
await manager.linkUserInDatabase(user.id, sub);
|
||||||
|
|
||||||
|
userId = user.id;
|
||||||
|
} else {
|
||||||
return returnError(
|
return returnError(
|
||||||
context,
|
context,
|
||||||
{
|
{
|
||||||
|
|
@ -224,80 +211,96 @@ export default apiRoute((app) =>
|
||||||
"No user found with that account",
|
"No user found with that account",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!user.hasPermission(RolePermissions.OAuth)) {
|
const user = await User.fromId(userId);
|
||||||
return returnError(
|
|
||||||
context,
|
|
||||||
{
|
|
||||||
redirect_uri: flow.application?.redirectUri,
|
|
||||||
client_id: flow.application?.clientId,
|
|
||||||
response_type: "code",
|
|
||||||
scope: flow.application?.scopes,
|
|
||||||
},
|
|
||||||
"invalid_request",
|
|
||||||
`User does not have the '${RolePermissions.OAuth}' permission`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!flow.application) {
|
if (!user) {
|
||||||
return context.json({ error: "Application not found" }, 500);
|
return returnError(
|
||||||
}
|
context,
|
||||||
|
{
|
||||||
const code = randomString(32, "hex");
|
redirect_uri: flow.application?.redirectUri,
|
||||||
|
client_id: flow.application?.clientId,
|
||||||
await db.insert(Tokens).values({
|
response_type: "code",
|
||||||
accessToken: randomString(64, "base64url"),
|
scope: flow.application?.scopes,
|
||||||
code: code,
|
},
|
||||||
scope: flow.application.scopes,
|
"invalid_request",
|
||||||
tokenType: TokenType.Bearer,
|
"No user found with that account",
|
||||||
userId: user.id,
|
|
||||||
applicationId: flow.application.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try and import the key
|
|
||||||
const privateKey = await crypto.subtle.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
|
|
||||||
"Ed25519",
|
|
||||||
false,
|
|
||||||
["sign"],
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate JWT
|
if (!user.hasPermission(RolePermissions.OAuth)) {
|
||||||
const jwt = await new SignJWT({
|
return returnError(
|
||||||
sub: user.id,
|
context,
|
||||||
iss: new URL(config.http.base_url).origin,
|
{
|
||||||
aud: flow.application.clientId,
|
redirect_uri: flow.application?.redirectUri,
|
||||||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
client_id: flow.application?.clientId,
|
||||||
iat: Math.floor(Date.now() / 1000),
|
response_type: "code",
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
scope: flow.application?.scopes,
|
||||||
})
|
},
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
"invalid_request",
|
||||||
.sign(privateKey);
|
`User does not have the '${RolePermissions.OAuth}' permission`,
|
||||||
|
|
||||||
// Redirect back to application
|
|
||||||
setCookie(context, "jwt", jwt, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
sameSite: "strict",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.redirect(
|
|
||||||
new URL(
|
|
||||||
`${config.frontend.routes.consent}?${new URLSearchParams({
|
|
||||||
redirect_uri: flow.application.redirectUri,
|
|
||||||
code,
|
|
||||||
client_id: flow.application.clientId,
|
|
||||||
application: flow.application.name,
|
|
||||||
website: flow.application.website ?? "",
|
|
||||||
scope: flow.application.scopes,
|
|
||||||
response_type: "code",
|
|
||||||
}).toString()}`,
|
|
||||||
config.http.base_url,
|
|
||||||
).toString(),
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
),
|
|
||||||
|
if (!flow.application) {
|
||||||
|
return context.json({ error: "Application not found" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = randomString(32, "hex");
|
||||||
|
|
||||||
|
await db.insert(Tokens).values({
|
||||||
|
accessToken: randomString(64, "base64url"),
|
||||||
|
code: code,
|
||||||
|
scope: flow.application.scopes,
|
||||||
|
tokenType: TokenType.Bearer,
|
||||||
|
userId: user.id,
|
||||||
|
applicationId: flow.application.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try and import the key
|
||||||
|
const privateKey = await crypto.subtle.importKey(
|
||||||
|
"pkcs8",
|
||||||
|
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
|
||||||
|
"Ed25519",
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const jwt = await new SignJWT({
|
||||||
|
sub: user.id,
|
||||||
|
iss: new URL(config.http.base_url).origin,
|
||||||
|
aud: flow.application.clientId,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
|
.sign(privateKey);
|
||||||
|
|
||||||
|
// Redirect back to application
|
||||||
|
setCookie(context, "jwt", jwt, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
new URL(
|
||||||
|
`${config.frontend.routes.consent}?${new URLSearchParams({
|
||||||
|
redirect_uri: flow.application.redirectUri,
|
||||||
|
code,
|
||||||
|
client_id: flow.application.clientId,
|
||||||
|
application: flow.application.name,
|
||||||
|
website: flow.application.website ?? "",
|
||||||
|
scope: flow.application.scopes,
|
||||||
|
response_type: "code",
|
||||||
|
}).toString()}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).toString(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
import { oauthRedirectUri } from "@/constants";
|
import { oauthRedirectUri } from "@/constants";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import {
|
import {
|
||||||
calculatePKCECodeChallenge,
|
calculatePKCECodeChallenge,
|
||||||
|
|
@ -35,6 +35,21 @@ export const schemas = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/oauth/sso",
|
||||||
|
summary: "Initiate SSO login flow",
|
||||||
|
request: {
|
||||||
|
query: schemas.query,
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
302: {
|
||||||
|
description:
|
||||||
|
"Redirect to SSO login, or redirect to login page with error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const returnError = (
|
const returnError = (
|
||||||
context: Context,
|
context: Context,
|
||||||
query: object,
|
query: object,
|
||||||
|
|
@ -59,87 +74,80 @@ const returnError = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(
|
app.openapi(route, async (context) => {
|
||||||
meta.allowedMethods,
|
// This is the Versia client's client_id, not the external OAuth provider's client_id
|
||||||
meta.route,
|
const { issuer: issuerId, client_id } = context.req.valid("query");
|
||||||
zValidator("query", schemas.query, handleZodError),
|
const body = await context.req.query();
|
||||||
async (context) => {
|
|
||||||
// This is the Versia client's client_id, not the external OAuth provider's client_id
|
|
||||||
const { issuer: issuerId, client_id } = context.req.valid("query");
|
|
||||||
const body = await context.req.query();
|
|
||||||
|
|
||||||
if (!client_id || client_id === "undefined") {
|
if (!client_id || client_id === "undefined") {
|
||||||
return returnError(
|
return returnError(
|
||||||
context,
|
context,
|
||||||
body,
|
body,
|
||||||
"invalid_request",
|
"invalid_request",
|
||||||
"client_id is required",
|
"client_id is required",
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const issuer = config.oidc.providers.find(
|
|
||||||
(provider) => provider.id === issuerId,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!issuer) {
|
const issuer = config.oidc.providers.find(
|
||||||
return returnError(
|
(provider) => provider.id === issuerId,
|
||||||
context,
|
);
|
||||||
body,
|
|
||||||
"invalid_request",
|
|
||||||
"issuer is invalid",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const issuerUrl = new URL(issuer.url);
|
if (!issuer) {
|
||||||
|
return returnError(
|
||||||
const authServer = await discoveryRequest(issuerUrl, {
|
context,
|
||||||
algorithm: "oidc",
|
body,
|
||||||
}).then((res) => processDiscoveryResponse(issuerUrl, res));
|
"invalid_request",
|
||||||
|
"issuer is invalid",
|
||||||
const codeVerifier = generateRandomCodeVerifier();
|
|
||||||
|
|
||||||
const application = await db.query.Applications.findFirst({
|
|
||||||
where: (application, { eq }) =>
|
|
||||||
eq(application.clientId, client_id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!application) {
|
|
||||||
return returnError(
|
|
||||||
context,
|
|
||||||
body,
|
|
||||||
"invalid_request",
|
|
||||||
"client_id is invalid",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store into database
|
|
||||||
const newFlow = (
|
|
||||||
await db
|
|
||||||
.insert(OpenIdLoginFlows)
|
|
||||||
.values({
|
|
||||||
codeVerifier,
|
|
||||||
applicationId: application.id,
|
|
||||||
issuerId,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
const codeChallenge =
|
|
||||||
await calculatePKCECodeChallenge(codeVerifier);
|
|
||||||
|
|
||||||
return context.redirect(
|
|
||||||
`${authServer.authorization_endpoint}?${new URLSearchParams({
|
|
||||||
client_id: issuer.client_id,
|
|
||||||
redirect_uri: `${oauthRedirectUri(issuerId)}?flow=${
|
|
||||||
newFlow.id
|
|
||||||
}`,
|
|
||||||
response_type: "code",
|
|
||||||
scope: "openid profile email",
|
|
||||||
// PKCE
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
code_challenge: codeChallenge,
|
|
||||||
}).toString()}`,
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
),
|
|
||||||
|
const issuerUrl = new URL(issuer.url);
|
||||||
|
|
||||||
|
const authServer = await discoveryRequest(issuerUrl, {
|
||||||
|
algorithm: "oidc",
|
||||||
|
}).then((res) => processDiscoveryResponse(issuerUrl, res));
|
||||||
|
|
||||||
|
const codeVerifier = generateRandomCodeVerifier();
|
||||||
|
|
||||||
|
const application = await db.query.Applications.findFirst({
|
||||||
|
where: (application, { eq }) => eq(application.clientId, client_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!application) {
|
||||||
|
return returnError(
|
||||||
|
context,
|
||||||
|
body,
|
||||||
|
"invalid_request",
|
||||||
|
"client_id is invalid",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store into database
|
||||||
|
const newFlow = (
|
||||||
|
await db
|
||||||
|
.insert(OpenIdLoginFlows)
|
||||||
|
.values({
|
||||||
|
codeVerifier,
|
||||||
|
applicationId: application.id,
|
||||||
|
issuerId,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${authServer.authorization_endpoint}?${new URLSearchParams({
|
||||||
|
client_id: issuer.client_id,
|
||||||
|
redirect_uri: `${oauthRedirectUri(issuerId)}?flow=${
|
||||||
|
newFlow.id
|
||||||
|
}`,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid profile email",
|
||||||
|
// PKCE
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
}).toString()}`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { apiRoute, applyConfig, handleZodError, jsonOrForm } from "@/api";
|
import { apiRoute, applyConfig, jsonOrForm } from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~/drizzle/db";
|
import { db } from "~/drizzle/db";
|
||||||
|
|
@ -50,90 +50,137 @@ export const schemas = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "post",
|
||||||
|
path: "/oauth/token",
|
||||||
|
summary: "Get token",
|
||||||
|
middleware: [jsonOrForm()],
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: schemas.json,
|
||||||
|
},
|
||||||
|
"application/x-www-form-urlencoded": {
|
||||||
|
schema: schemas.json,
|
||||||
|
},
|
||||||
|
"multipart/form-data": {
|
||||||
|
schema: schemas.json,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Token",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
access_token: z.string(),
|
||||||
|
token_type: z.string(),
|
||||||
|
expires_in: z.number().optional().nullable(),
|
||||||
|
id_token: z.string().optional().nullable(),
|
||||||
|
refresh_token: z.string().optional().nullable(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
created_at: z.number(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Authorization error",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
error: z.string(),
|
||||||
|
error_description: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(
|
app.openapi(route, async (context) => {
|
||||||
meta.allowedMethods,
|
const { grant_type, code, redirect_uri, client_id, client_secret } =
|
||||||
meta.route,
|
context.req.valid("json");
|
||||||
jsonOrForm(),
|
|
||||||
zValidator("json", schemas.json, handleZodError),
|
|
||||||
async (context) => {
|
|
||||||
const { grant_type, code, redirect_uri, client_id, client_secret } =
|
|
||||||
context.req.valid("json");
|
|
||||||
|
|
||||||
switch (grant_type) {
|
switch (grant_type) {
|
||||||
case "authorization_code": {
|
case "authorization_code": {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "invalid_request",
|
error: "invalid_request",
|
||||||
error_description: "Code is required",
|
error_description: "Code is required",
|
||||||
},
|
},
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!redirect_uri) {
|
if (!redirect_uri) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "invalid_request",
|
error: "invalid_request",
|
||||||
error_description: "Redirect URI is required",
|
error_description: "Redirect URI is required",
|
||||||
},
|
},
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client_id) {
|
if (!client_id) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "invalid_request",
|
error: "invalid_request",
|
||||||
error_description: "Client ID is required",
|
error_description: "Client ID is required",
|
||||||
},
|
},
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the client_secret
|
// Verify the client_secret
|
||||||
const client = await db.query.Applications.findFirst({
|
const client = await db.query.Applications.findFirst({
|
||||||
where: (application, { eq }) =>
|
where: (application, { eq }) =>
|
||||||
eq(application.clientId, client_id),
|
eq(application.clientId, client_id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!client || client.secret !== client_secret) {
|
if (!client || client.secret !== client_secret) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "invalid_client",
|
error: "invalid_client",
|
||||||
error_description: "Invalid client credentials",
|
error_description: "Invalid client credentials",
|
||||||
},
|
},
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await db.query.Tokens.findFirst({
|
const token = await db.query.Tokens.findFirst({
|
||||||
where: (token, { eq, and }) =>
|
where: (token, { eq, and }) =>
|
||||||
and(
|
and(
|
||||||
eq(token.code, code),
|
eq(token.code, code),
|
||||||
eq(token.redirectUri, decodeURI(redirect_uri)),
|
eq(token.redirectUri, decodeURI(redirect_uri)),
|
||||||
eq(token.clientId, client_id),
|
eq(token.clientId, client_id),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "invalid_grant",
|
error: "invalid_grant",
|
||||||
error_description: "Code not found",
|
error_description: "Code not found",
|
||||||
},
|
},
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate the code
|
// Invalidate the code
|
||||||
await db
|
await db
|
||||||
.update(Tokens)
|
.update(Tokens)
|
||||||
.set({ code: null })
|
.set({ code: null })
|
||||||
.where(eq(Tokens.id, token.id));
|
.where(eq(Tokens.id, token.id));
|
||||||
|
|
||||||
return context.json({
|
return context.json(
|
||||||
|
{
|
||||||
access_token: token.accessToken,
|
access_token: token.accessToken,
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: token.expiresAt
|
expires_in: token.expiresAt
|
||||||
|
|
@ -149,17 +196,18 @@ export default apiRoute((app) =>
|
||||||
created_at: Math.floor(
|
created_at: Math.floor(
|
||||||
new Date(token.createdAt).getTime() / 1000,
|
new Date(token.createdAt).getTime() / 1000,
|
||||||
),
|
),
|
||||||
});
|
},
|
||||||
}
|
200,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "unsupported_grant_type",
|
error: "unsupported_grant_type",
|
||||||
error_description: "Unsupported grant type",
|
error_description: "Unsupported grant type",
|
||||||
},
|
},
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
|
import {
|
||||||
|
LikeExtension as LikeSchema,
|
||||||
|
Note as NoteSchema,
|
||||||
|
} from "@versia/federation/schemas";
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { type LikeType, likeToVersia } from "~/classes/functions/like";
|
import { type LikeType, likeToVersia } from "~/classes/functions/like";
|
||||||
|
|
@ -8,7 +12,7 @@ import { Notes } from "~/drizzle/schema";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
import { Note } from "~/packages/database-interface/note";
|
import { Note } from "~/packages/database-interface/note";
|
||||||
import { User } from "~/packages/database-interface/user";
|
import { User } from "~/packages/database-interface/user";
|
||||||
import type { KnownEntity } from "~/types/api";
|
import { ErrorSchema, type KnownEntity } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -28,77 +32,103 @@ export const schemas = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/objects/{id}",
|
||||||
|
summary: "Get object",
|
||||||
|
request: {
|
||||||
|
params: schemas.param,
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Object",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: NoteSchema.or(LikeSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Object not found",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Cannot view objects from remote instances",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(
|
app.openapi(route, async (context) => {
|
||||||
meta.allowedMethods,
|
const { id } = context.req.valid("param");
|
||||||
meta.route,
|
|
||||||
zValidator("param", schemas.param, handleZodError),
|
|
||||||
async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
|
|
||||||
let foundObject: Note | LikeType | null = null;
|
let foundObject: Note | LikeType | null = null;
|
||||||
let foundAuthor: User | null = null;
|
let foundAuthor: User | null = null;
|
||||||
let apiObject: KnownEntity | null = null;
|
let apiObject: KnownEntity | null = null;
|
||||||
|
|
||||||
foundObject = await Note.fromSql(
|
foundObject = await Note.fromSql(
|
||||||
and(
|
and(
|
||||||
eq(Notes.id, id),
|
eq(Notes.id, id),
|
||||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
apiObject = foundObject ? foundObject.toVersia() : null;
|
apiObject = foundObject ? foundObject.toVersia() : null;
|
||||||
foundAuthor = foundObject ? foundObject.author : null;
|
foundAuthor = foundObject ? foundObject.author : null;
|
||||||
|
|
||||||
if (foundObject) {
|
if (foundObject) {
|
||||||
if (!foundObject.isViewableByUser(null)) {
|
if (!foundObject.isViewableByUser(null)) {
|
||||||
return context.json({ error: "Object not found" }, 404);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
foundObject =
|
|
||||||
(await db.query.Likes.findFirst({
|
|
||||||
where: (like, { eq, and }) =>
|
|
||||||
and(
|
|
||||||
eq(like.id, id),
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${like.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`,
|
|
||||||
),
|
|
||||||
})) ?? null;
|
|
||||||
apiObject = foundObject ? likeToVersia(foundObject) : null;
|
|
||||||
foundAuthor = foundObject
|
|
||||||
? await User.fromId(foundObject.likerId)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(foundObject && apiObject)) {
|
|
||||||
return context.json({ error: "Object not found" }, 404);
|
return context.json({ error: "Object not found" }, 404);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
foundObject =
|
||||||
|
(await db.query.Likes.findFirst({
|
||||||
|
where: (like, { eq, and }) =>
|
||||||
|
and(
|
||||||
|
eq(like.id, id),
|
||||||
|
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${like.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`,
|
||||||
|
),
|
||||||
|
})) ?? null;
|
||||||
|
apiObject = foundObject ? likeToVersia(foundObject) : null;
|
||||||
|
foundAuthor = foundObject
|
||||||
|
? await User.fromId(foundObject.likerId)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!foundAuthor) {
|
if (!(foundObject && apiObject)) {
|
||||||
return context.json({ error: "Author not found" }, 404);
|
return context.json({ error: "Object not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundAuthor?.isRemote()) {
|
if (!foundAuthor) {
|
||||||
return context.json(
|
return context.json({ error: "Author not found" }, 404);
|
||||||
{ error: "Cannot view objects from remote instances" },
|
}
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If base_url uses https and request uses http, rewrite request to use https
|
|
||||||
// This fixes reverse proxy errors
|
|
||||||
const reqUrl = new URL(context.req.url);
|
|
||||||
if (
|
|
||||||
new URL(config.http.base_url).protocol === "https:" &&
|
|
||||||
reqUrl.protocol === "http:"
|
|
||||||
) {
|
|
||||||
reqUrl.protocol = "https:";
|
|
||||||
}
|
|
||||||
|
|
||||||
const { headers } = await foundAuthor.sign(
|
if (foundAuthor?.isRemote()) {
|
||||||
apiObject,
|
return context.json(
|
||||||
reqUrl,
|
{ error: "Cannot view objects from remote instances" },
|
||||||
"GET",
|
403,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
// If base_url uses https and request uses http, rewrite request to use https
|
||||||
|
// This fixes reverse proxy errors
|
||||||
|
const reqUrl = new URL(context.req.url);
|
||||||
|
if (
|
||||||
|
new URL(config.http.base_url).protocol === "https:" &&
|
||||||
|
reqUrl.protocol === "http:"
|
||||||
|
) {
|
||||||
|
reqUrl.protocol = "https:";
|
||||||
|
}
|
||||||
|
|
||||||
return context.json(apiObject, 200, headers.toJSON());
|
const { headers } = await foundAuthor.sign(apiObject, reqUrl, "GET");
|
||||||
},
|
|
||||||
),
|
return context.json(apiObject, 200, headers.toJSON());
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { apiRoute, applyConfig, debugRequest, handleZodError } from "@/api";
|
import { apiRoute, applyConfig, debugRequest } from "@/api";
|
||||||
import { sentry } from "@/sentry";
|
import { sentry } from "@/sentry";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { getLogger } from "@logtape/logtape";
|
import { getLogger } from "@logtape/logtape";
|
||||||
import {
|
import {
|
||||||
EntityValidator,
|
EntityValidator,
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
SignatureValidator,
|
SignatureValidator,
|
||||||
} from "@versia/federation";
|
} from "@versia/federation";
|
||||||
import type { Entity } from "@versia/federation/types";
|
import type { Entity } from "@versia/federation/types";
|
||||||
import type { SocketAddress } from "bun";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { matches } from "ip-matching";
|
import { matches } from "ip-matching";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -20,6 +19,7 @@ import { config } from "~/packages/config-manager";
|
||||||
import { Note } from "~/packages/database-interface/note";
|
import { Note } from "~/packages/database-interface/note";
|
||||||
import { Relationship } from "~/packages/database-interface/relationship";
|
import { Relationship } from "~/packages/database-interface/relationship";
|
||||||
import { User } from "~/packages/database-interface/user";
|
import { User } from "~/packages/database-interface/user";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -46,388 +46,413 @@ export const schemas = {
|
||||||
body: z.any(),
|
body: z.any(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
const route = createRoute({
|
||||||
app.on(
|
method: "post",
|
||||||
meta.allowedMethods,
|
path: "/users/{uuid}/inbox",
|
||||||
meta.route,
|
summary: "Receive federation inbox",
|
||||||
zValidator("param", schemas.param, handleZodError),
|
request: {
|
||||||
zValidator("header", schemas.header, handleZodError),
|
params: schemas.param,
|
||||||
zValidator("json", schemas.body, handleZodError),
|
headers: schemas.header,
|
||||||
async (context) => {
|
body: {
|
||||||
const { uuid } = context.req.valid("param");
|
content: {
|
||||||
const {
|
"application/json": {
|
||||||
"x-signature": signature,
|
schema: schemas.body,
|
||||||
"x-nonce": nonce,
|
},
|
||||||
"x-signed-by": signedBy,
|
},
|
||||||
authorization,
|
},
|
||||||
} = context.req.valid("header");
|
},
|
||||||
const logger = getLogger(["federation", "inbox"]);
|
responses: {
|
||||||
|
200: {
|
||||||
const body: Entity = await context.req.valid("json");
|
description: "Request processed",
|
||||||
|
},
|
||||||
if (config.debug.federation) {
|
201: {
|
||||||
// Debug request
|
description: "Request accepted",
|
||||||
await debugRequest(
|
},
|
||||||
new Request(context.req.url, {
|
400: {
|
||||||
method: context.req.method,
|
description: "Bad request",
|
||||||
headers: context.req.raw.headers,
|
content: {
|
||||||
body: await context.req.text(),
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Signature could not be verified",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Cannot view users from remote instances",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Not found",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
error: z.string(),
|
||||||
|
message: z.string(),
|
||||||
}),
|
}),
|
||||||
);
|
},
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const user = await User.fromId(uuid);
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { uuid } = context.req.valid("param");
|
||||||
|
const {
|
||||||
|
"x-signature": signature,
|
||||||
|
"x-nonce": nonce,
|
||||||
|
"x-signed-by": signedBy,
|
||||||
|
authorization,
|
||||||
|
} = context.req.valid("header");
|
||||||
|
const logger = getLogger(["federation", "inbox"]);
|
||||||
|
|
||||||
if (!user) {
|
const body: Entity = await context.req.valid("json");
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.isRemote()) {
|
if (config.debug.federation) {
|
||||||
return context.json(
|
// Debug request
|
||||||
{ error: "Cannot view users from remote instances" },
|
await debugRequest(
|
||||||
403,
|
new Request(context.req.url, {
|
||||||
);
|
method: context.req.method,
|
||||||
}
|
headers: context.req.raw.headers,
|
||||||
|
body: await context.req.text(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// @ts-expect-error IP attribute is not in types
|
const user = await User.fromId(uuid);
|
||||||
const requestIp = context.env?.ip as
|
|
||||||
| SocketAddress
|
|
||||||
| undefined
|
|
||||||
| null;
|
|
||||||
|
|
||||||
let checkSignature = true;
|
if (!user) {
|
||||||
|
return context.json({ error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
if (config.federation.bridge.enabled) {
|
if (user.isRemote()) {
|
||||||
const token = authorization?.split("Bearer ")[1];
|
return context.json(
|
||||||
if (token) {
|
{ error: "Cannot view users from remote instances" },
|
||||||
// Request is bridge request
|
403,
|
||||||
if (token !== config.federation.bridge.token) {
|
);
|
||||||
return context.json(
|
}
|
||||||
{
|
|
||||||
error: "An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header.",
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestIp?.address) {
|
const requestIp = context.env?.ip;
|
||||||
if (config.federation.bridge.allowed_ips.length > 0) {
|
|
||||||
checkSignature = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const ip of config.federation.bridge.allowed_ips) {
|
let checkSignature = true;
|
||||||
if (matches(ip, requestIp?.address)) {
|
|
||||||
checkSignature = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "Request IP address is not available",
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sender = await User.resolve(signedBy);
|
if (config.federation.bridge.enabled) {
|
||||||
|
const token = authorization?.split("Bearer ")[1];
|
||||||
if (sender?.isLocal()) {
|
if (token) {
|
||||||
return context.json(
|
// Request is bridge request
|
||||||
{ error: "Cannot send federation requests to local users" },
|
if (token !== config.federation.bridge.token) {
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = sender?.data.instance?.baseUrl ?? "";
|
|
||||||
|
|
||||||
// Check if Origin is defederated
|
|
||||||
if (
|
|
||||||
config.federation.blocked.find(
|
|
||||||
(blocked) =>
|
|
||||||
blocked.includes(hostname) ||
|
|
||||||
hostname.includes(blocked),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// Pretend to accept request
|
|
||||||
return context.newResponse(null, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify request signature
|
|
||||||
if (checkSignature) {
|
|
||||||
if (!sender) {
|
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: "Could not resolve sender" },
|
{
|
||||||
400,
|
error: "An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header.",
|
||||||
|
},
|
||||||
|
401,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.debug.federation) {
|
if (requestIp?.address) {
|
||||||
// Log public key
|
if (config.federation.bridge.allowed_ips.length > 0) {
|
||||||
logger.debug`Sender public key: ${sender.data.publicKey}`;
|
checkSignature = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validator = await SignatureValidator.fromStringKey(
|
for (const ip of config.federation.bridge.allowed_ips) {
|
||||||
sender.data.publicKey,
|
if (matches(ip, requestIp?.address)) {
|
||||||
);
|
checkSignature = false;
|
||||||
|
break;
|
||||||
const isValid = await validator
|
}
|
||||||
.validate(
|
}
|
||||||
new Request(context.req.url, {
|
} else {
|
||||||
method: context.req.method,
|
return context.json(
|
||||||
headers: {
|
{
|
||||||
"X-Signature": signature,
|
error: "Request IP address is not available",
|
||||||
"X-Nonce": nonce,
|
},
|
||||||
},
|
500,
|
||||||
body: await context.req.text(),
|
);
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error`${e}`;
|
|
||||||
sentry?.captureException(e);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return context.json({ error: "Invalid signature" }, 401);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const validator = new EntityValidator();
|
const sender = await User.resolve(signedBy);
|
||||||
const handler = new RequestParserHandler(body, validator);
|
|
||||||
|
|
||||||
try {
|
if (sender?.isLocal()) {
|
||||||
return await handler.parseBody<Response>({
|
return context.json(
|
||||||
note: async (note) => {
|
{ error: "Cannot send federation requests to local users" },
|
||||||
const account = await User.resolve(note.author);
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!account) {
|
const hostname = sender?.data.instance?.baseUrl ?? "";
|
||||||
return context.json(
|
|
||||||
{ error: "Author not found" },
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newStatus = await Note.fromVersia(
|
// Check if Origin is defederated
|
||||||
note,
|
if (
|
||||||
account,
|
config.federation.blocked.find(
|
||||||
).catch((e) => {
|
(blocked) =>
|
||||||
logger.error`${e}`;
|
blocked.includes(hostname) || hostname.includes(blocked),
|
||||||
sentry?.captureException(e);
|
)
|
||||||
return null;
|
) {
|
||||||
});
|
// Pretend to accept request
|
||||||
|
return context.newResponse(null, 201);
|
||||||
|
}
|
||||||
|
|
||||||
if (!newStatus) {
|
// Verify request signature
|
||||||
return context.json(
|
if (checkSignature) {
|
||||||
{ error: "Failed to add status" },
|
if (!sender) {
|
||||||
500,
|
return context.json({ error: "Could not resolve sender" }, 400);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return context.text("Note created", 201);
|
if (config.debug.federation) {
|
||||||
},
|
// Log public key
|
||||||
follow: async (follow) => {
|
logger.debug`Sender public key: ${sender.data.publicKey}`;
|
||||||
const account = await User.resolve(follow.author);
|
}
|
||||||
|
|
||||||
if (!account) {
|
const validator = await SignatureValidator.fromStringKey(
|
||||||
return context.json(
|
sender.data.publicKey,
|
||||||
{ error: "Author not found" },
|
);
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship =
|
const isValid = await validator
|
||||||
await Relationship.fromOwnerAndSubject(
|
.validate(
|
||||||
account,
|
new Request(context.req.url, {
|
||||||
user,
|
method: context.req.method,
|
||||||
|
headers: {
|
||||||
|
"X-Signature": signature,
|
||||||
|
"X-Nonce": nonce,
|
||||||
|
},
|
||||||
|
body: await context.req.text(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error`${e}`;
|
||||||
|
sentry?.captureException(e);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Signature could not be verified" },
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validator = new EntityValidator();
|
||||||
|
const handler = new RequestParserHandler(body, validator);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await handler.parseBody<Response>({
|
||||||
|
note: async (note) => {
|
||||||
|
const account = await User.resolve(note.author);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return context.json({ error: "Author not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = await Note.fromVersia(
|
||||||
|
note,
|
||||||
|
account,
|
||||||
|
).catch((e) => {
|
||||||
|
logger.error`${e}`;
|
||||||
|
sentry?.captureException(e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!newStatus) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Failed to add status" },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.text("Note created", 201);
|
||||||
|
},
|
||||||
|
follow: async (follow) => {
|
||||||
|
const account = await User.resolve(follow.author);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return context.json({ error: "Author not found" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundRelationship =
|
||||||
|
await Relationship.fromOwnerAndSubject(account, user);
|
||||||
|
|
||||||
|
if (foundRelationship.data.following) {
|
||||||
|
return context.text("Already following", 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
await foundRelationship.update({
|
||||||
|
following: !user.data.isLocked,
|
||||||
|
requested: user.data.isLocked,
|
||||||
|
showingReblogs: true,
|
||||||
|
notifying: true,
|
||||||
|
languages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(Notifications).values({
|
||||||
|
accountId: account.id,
|
||||||
|
type: user.data.isLocked ? "follow_request" : "follow",
|
||||||
|
notifiedId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.data.isLocked) {
|
||||||
|
await sendFollowAccept(account, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.text("Follow request sent", 200);
|
||||||
|
},
|
||||||
|
followAccept: async (followAccept) => {
|
||||||
|
const account = await User.resolve(followAccept.author);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return context.json({ error: "Author not found" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundRelationship =
|
||||||
|
await Relationship.fromOwnerAndSubject(user, account);
|
||||||
|
|
||||||
|
if (!foundRelationship.data.requested) {
|
||||||
|
return context.text(
|
||||||
|
"There is no follow request to accept",
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await foundRelationship.update({
|
||||||
|
requested: false,
|
||||||
|
following: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.text("Follow request accepted", 200);
|
||||||
|
},
|
||||||
|
followReject: async (followReject) => {
|
||||||
|
const account = await User.resolve(followReject.author);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return context.json({ error: "Author not found" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundRelationship =
|
||||||
|
await Relationship.fromOwnerAndSubject(user, account);
|
||||||
|
|
||||||
|
if (!foundRelationship.data.requested) {
|
||||||
|
return context.text(
|
||||||
|
"There is no follow request to reject",
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await foundRelationship.update({
|
||||||
|
requested: false,
|
||||||
|
following: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.text("Follow request rejected", 200);
|
||||||
|
},
|
||||||
|
// "delete" is a reserved keyword in JS
|
||||||
|
delete: async (delete_) => {
|
||||||
|
// Delete the specified object from database, if it exists and belongs to the user
|
||||||
|
const toDelete = delete_.target;
|
||||||
|
|
||||||
|
switch (delete_.deleted_type) {
|
||||||
|
case "Note": {
|
||||||
|
const note = await Note.fromSql(
|
||||||
|
eq(Notes.uri, toDelete),
|
||||||
|
eq(Notes.authorId, user.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (foundRelationship.data.following) {
|
if (note) {
|
||||||
return context.text("Already following", 200);
|
await note.delete();
|
||||||
}
|
return context.text("Note deleted", 200);
|
||||||
|
|
||||||
await foundRelationship.update({
|
|
||||||
following: !user.data.isLocked,
|
|
||||||
requested: user.data.isLocked,
|
|
||||||
showingReblogs: true,
|
|
||||||
notifying: true,
|
|
||||||
languages: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(Notifications).values({
|
|
||||||
accountId: account.id,
|
|
||||||
type: user.data.isLocked
|
|
||||||
? "follow_request"
|
|
||||||
: "follow",
|
|
||||||
notifiedId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user.data.isLocked) {
|
|
||||||
await sendFollowAccept(account, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.text("Follow request sent", 200);
|
|
||||||
},
|
|
||||||
followAccept: async (followAccept) => {
|
|
||||||
const account = await User.resolve(followAccept.author);
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
return context.json(
|
|
||||||
{ error: "Author not found" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship =
|
|
||||||
await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
account,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!foundRelationship.data.requested) {
|
|
||||||
return context.text(
|
|
||||||
"There is no follow request to accept",
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await foundRelationship.update({
|
|
||||||
requested: false,
|
|
||||||
following: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.text("Follow request accepted", 200);
|
|
||||||
},
|
|
||||||
followReject: async (followReject) => {
|
|
||||||
const account = await User.resolve(followReject.author);
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
return context.json(
|
|
||||||
{ error: "Author not found" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship =
|
|
||||||
await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
account,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!foundRelationship.data.requested) {
|
|
||||||
return context.text(
|
|
||||||
"There is no follow request to reject",
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await foundRelationship.update({
|
|
||||||
requested: false,
|
|
||||||
following: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.text("Follow request rejected", 200);
|
|
||||||
},
|
|
||||||
// "delete" is a reserved keyword in JS
|
|
||||||
delete: async (delete_) => {
|
|
||||||
// Delete the specified object from database, if it exists and belongs to the user
|
|
||||||
const toDelete = delete_.target;
|
|
||||||
|
|
||||||
switch (delete_.deleted_type) {
|
|
||||||
case "Note": {
|
|
||||||
const note = await Note.fromSql(
|
|
||||||
eq(Notes.uri, toDelete),
|
|
||||||
eq(Notes.authorId, user.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (note) {
|
|
||||||
await note.delete();
|
|
||||||
return context.text("Note deleted", 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case "User": {
|
|
||||||
const otherUser = await User.resolve(toDelete);
|
|
||||||
|
|
||||||
if (otherUser) {
|
break;
|
||||||
if (otherUser.id === user.id) {
|
}
|
||||||
// Delete own account
|
case "User": {
|
||||||
await user.delete();
|
const otherUser = await User.resolve(toDelete);
|
||||||
return context.text(
|
|
||||||
"Account deleted",
|
if (otherUser) {
|
||||||
200,
|
if (otherUser.id === user.id) {
|
||||||
);
|
// Delete own account
|
||||||
}
|
await user.delete();
|
||||||
return context.json(
|
return context.text("Account deleted", 200);
|
||||||
{
|
|
||||||
error: "Cannot delete other users than self",
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: `Deletetion of object ${toDelete} not implemented`,
|
error: "Cannot delete other users than self",
|
||||||
},
|
},
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
default: {
|
||||||
return context.json(
|
|
||||||
{ error: "Object not found or not owned by user" },
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
user: async (user) => {
|
|
||||||
// Refetch user to ensure we have the latest data
|
|
||||||
const updatedAccount = await User.saveFromRemote(
|
|
||||||
user.uri,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!updatedAccount) {
|
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: "Failed to update user" },
|
{
|
||||||
500,
|
error: `Deletetion of object ${toDelete} not implemented`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return context.text("User refreshed", 200);
|
|
||||||
},
|
|
||||||
unknown: () => {
|
|
||||||
return context.json(
|
|
||||||
{ error: "Unknown entity type" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (isValidationError(e)) {
|
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{ error: "Object not found or not owned by user" },
|
||||||
error: "Failed to process request",
|
404,
|
||||||
error_description: (e as ValidationError).message,
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
logger.error`${e}`;
|
user: async (user) => {
|
||||||
sentry?.captureException(e);
|
// Refetch user to ensure we have the latest data
|
||||||
|
const updatedAccount = await User.saveFromRemote(user.uri);
|
||||||
|
|
||||||
|
if (!updatedAccount) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Failed to update user" },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.text("User refreshed", 200);
|
||||||
|
},
|
||||||
|
unknown: () => {
|
||||||
|
return context.json({ error: "Unknown entity type" }, 400);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (isValidationError(e)) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{
|
{
|
||||||
error: "Failed to process request",
|
error: "Failed to process request",
|
||||||
message: (e as Error).message,
|
error_description: (e as ValidationError).message,
|
||||||
},
|
},
|
||||||
500,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
logger.error`${e}`;
|
||||||
),
|
sentry?.captureException(e);
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "Failed to process request",
|
||||||
|
message: (e as Error).message,
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
|
import { User as UserSchema } from "@versia/federation/schemas";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { User } from "~/packages/database-interface/user";
|
import { User } from "~/packages/database-interface/user";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -21,44 +23,71 @@ export const schemas = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
const route = createRoute({
|
||||||
app.on(
|
method: "get",
|
||||||
meta.allowedMethods,
|
path: "/users/{uuid}",
|
||||||
meta.route,
|
summary: "Get user data",
|
||||||
zValidator("param", schemas.param, handleZodError),
|
request: {
|
||||||
async (context) => {
|
params: schemas.param,
|
||||||
const { uuid } = context.req.valid("param");
|
},
|
||||||
|
responses: {
|
||||||
const user = await User.fromId(uuid);
|
200: {
|
||||||
|
description: "User data",
|
||||||
if (!user) {
|
content: {
|
||||||
return context.json({ error: "User not found" }, 404);
|
"application/json": {
|
||||||
}
|
schema: UserSchema,
|
||||||
|
},
|
||||||
if (user.isRemote()) {
|
},
|
||||||
return context.json(
|
|
||||||
{ error: "Cannot view users from remote instances" },
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to detect a web browser and redirect to the user's profile page
|
|
||||||
if (
|
|
||||||
context.req.header("user-agent")?.includes("Mozilla") &&
|
|
||||||
uuid !== "actor"
|
|
||||||
) {
|
|
||||||
return context.redirect(user.toApi().url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userJson = user.toVersia();
|
|
||||||
|
|
||||||
const { headers } = await user.sign(
|
|
||||||
userJson,
|
|
||||||
context.req.url,
|
|
||||||
"GET",
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(userJson, 200, headers.toJSON());
|
|
||||||
},
|
},
|
||||||
),
|
301: {
|
||||||
|
description:
|
||||||
|
"Redirect to user profile (for web browsers). Uses user-agent for detection.",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "User not found",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Cannot view users from remote instances",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { uuid } = context.req.valid("param");
|
||||||
|
|
||||||
|
const user = await User.fromId(uuid);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return context.json({ error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isRemote()) {
|
||||||
|
return context.json(
|
||||||
|
{ error: "Cannot view users from remote instances" },
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect a web browser and redirect to the user's profile page
|
||||||
|
if (context.req.header("user-agent")?.includes("Mozilla")) {
|
||||||
|
return context.redirect(user.toApi().url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userJson = user.toVersia();
|
||||||
|
|
||||||
|
const { headers } = await user.sign(userJson, context.req.url, "GET");
|
||||||
|
|
||||||
|
return context.json(userJson, 200, headers.toJSON());
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import type { Collection } from "@versia/federation/types";
|
import {
|
||||||
|
Collection as CollectionSchema,
|
||||||
|
Note as NoteSchema,
|
||||||
|
} from "@versia/federation/schemas";
|
||||||
import { and, count, eq, inArray } from "drizzle-orm";
|
import { and, count, eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "~/drizzle/db";
|
import { db } from "~/drizzle/db";
|
||||||
|
|
@ -8,6 +11,7 @@ import { Notes } from "~/drizzle/schema";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
import { Note } from "~/packages/database-interface/note";
|
import { Note } from "~/packages/database-interface/note";
|
||||||
import { User } from "~/packages/database-interface/user";
|
import { User } from "~/packages/database-interface/user";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -30,89 +34,121 @@ export const schemas = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/users/{uuid}/outbox",
|
||||||
|
summary: "Get user outbox",
|
||||||
|
request: {
|
||||||
|
params: schemas.param,
|
||||||
|
query: schemas.query,
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "User outbox",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: CollectionSchema.extend({
|
||||||
|
items: z.array(NoteSchema),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "User not found",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Cannot view users from remote instances",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const NOTES_PER_PAGE = 20;
|
const NOTES_PER_PAGE = 20;
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(
|
app.openapi(route, async (context) => {
|
||||||
meta.allowedMethods,
|
const { uuid } = context.req.valid("param");
|
||||||
meta.route,
|
|
||||||
zValidator("param", schemas.param, handleZodError),
|
|
||||||
zValidator("query", schemas.query, handleZodError),
|
|
||||||
async (context) => {
|
|
||||||
const { uuid } = context.req.valid("param");
|
|
||||||
|
|
||||||
const author = await User.fromId(uuid);
|
const author = await User.fromId(uuid);
|
||||||
|
|
||||||
if (!author) {
|
if (!author) {
|
||||||
return context.json({ error: "User not found" }, 404);
|
return context.json({ error: "User not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (author.isRemote()) {
|
if (author.isRemote()) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: "Cannot view users from remote instances" },
|
{ error: "Cannot view users from remote instances" },
|
||||||
403,
|
403,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageNumber = Number(context.req.valid("query").page) || 1;
|
|
||||||
|
|
||||||
const notes = await Note.manyFromSql(
|
|
||||||
and(
|
|
||||||
eq(Notes.authorId, uuid),
|
|
||||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
|
||||||
),
|
|
||||||
undefined,
|
|
||||||
NOTES_PER_PAGE,
|
|
||||||
NOTES_PER_PAGE * (pageNumber - 1),
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const totalNotes = (
|
const pageNumber = Number(context.req.valid("query").page) || 1;
|
||||||
await db
|
|
||||||
.select({
|
|
||||||
count: count(),
|
|
||||||
})
|
|
||||||
.from(Notes)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(Notes.authorId, uuid),
|
|
||||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)[0].count;
|
|
||||||
|
|
||||||
const json = {
|
const notes = await Note.manyFromSql(
|
||||||
first: new URL(
|
and(
|
||||||
`/users/${uuid}/outbox?page=1`,
|
eq(Notes.authorId, uuid),
|
||||||
config.http.base_url,
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
).toString(),
|
),
|
||||||
last: new URL(
|
undefined,
|
||||||
`/users/${uuid}/outbox?page=${Math.ceil(
|
NOTES_PER_PAGE,
|
||||||
totalNotes / NOTES_PER_PAGE,
|
NOTES_PER_PAGE * (pageNumber - 1),
|
||||||
)}`,
|
);
|
||||||
config.http.base_url,
|
|
||||||
).toString(),
|
|
||||||
total: totalNotes,
|
|
||||||
author: author.getUri(),
|
|
||||||
next:
|
|
||||||
notes.length === NOTES_PER_PAGE
|
|
||||||
? new URL(
|
|
||||||
`/users/${uuid}/outbox?page=${pageNumber + 1}`,
|
|
||||||
config.http.base_url,
|
|
||||||
).toString()
|
|
||||||
: null,
|
|
||||||
previous:
|
|
||||||
pageNumber > 1
|
|
||||||
? new URL(
|
|
||||||
`/users/${uuid}/outbox?page=${pageNumber - 1}`,
|
|
||||||
config.http.base_url,
|
|
||||||
).toString()
|
|
||||||
: null,
|
|
||||||
items: notes.map((note) => note.toVersia()),
|
|
||||||
} satisfies Collection;
|
|
||||||
|
|
||||||
const { headers } = await author.sign(json, context.req.url, "GET");
|
const totalNotes = (
|
||||||
|
await db
|
||||||
|
.select({
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(Notes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Notes.authorId, uuid),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)[0].count;
|
||||||
|
|
||||||
return context.json(json, 200, headers.toJSON());
|
const json = {
|
||||||
},
|
first: new URL(
|
||||||
),
|
`/users/${uuid}/outbox?page=1`,
|
||||||
|
config.http.base_url,
|
||||||
|
).toString(),
|
||||||
|
last: new URL(
|
||||||
|
`/users/${uuid}/outbox?page=${Math.ceil(
|
||||||
|
totalNotes / NOTES_PER_PAGE,
|
||||||
|
)}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).toString(),
|
||||||
|
total: totalNotes,
|
||||||
|
author: author.getUri(),
|
||||||
|
next:
|
||||||
|
notes.length === NOTES_PER_PAGE
|
||||||
|
? new URL(
|
||||||
|
`/users/${uuid}/outbox?page=${pageNumber + 1}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).toString()
|
||||||
|
: null,
|
||||||
|
previous:
|
||||||
|
pageNumber > 1
|
||||||
|
? new URL(
|
||||||
|
`/users/${uuid}/outbox?page=${pageNumber - 1}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).toString()
|
||||||
|
: null,
|
||||||
|
items: notes.map((note) => note.toVersia()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { headers } = await author.sign(json, context.req.url, "GET");
|
||||||
|
|
||||||
|
return context.json(json, 200, headers.toJSON());
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -13,14 +14,34 @@ export const meta = applyConfig({
|
||||||
route: "/.well-known/host-meta",
|
route: "/.well-known/host-meta",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/.well-known/host-meta",
|
||||||
|
summary: "Well-known host-meta",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Host-meta",
|
||||||
|
content: {
|
||||||
|
"application/xrd+xml": {
|
||||||
|
schema: z.any(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(meta.allowedMethods, meta.route, (context) => {
|
app.openapi(route, (context) => {
|
||||||
context.header("Content-Type", "application/xrd+xml");
|
context.header("Content-Type", "application/xrd+xml");
|
||||||
|
context.status(200);
|
||||||
|
|
||||||
return context.body(
|
return context.body(
|
||||||
`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" template="${new URL(
|
`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" template="${new URL(
|
||||||
"/.well-known/webfinger",
|
"/.well-known/webfinger",
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
).toString()}?resource={uri}"/></XRD>`,
|
).toString()}?resource={uri}"/></XRD>`,
|
||||||
);
|
200,
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Hono doesn't type this response so this has a TS error, it's joever
|
||||||
|
) as any;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
import { exportJWK } from "jose";
|
import { exportJWK } from "jose";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
|
|
||||||
|
|
@ -14,8 +15,36 @@ export const meta = applyConfig({
|
||||||
route: "/.well-known/jwks",
|
route: "/.well-known/jwks",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/.well-known/jwks",
|
||||||
|
summary: "JWK Set",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "JWK Set",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
keys: z.array(
|
||||||
|
z.object({
|
||||||
|
kty: z.string(),
|
||||||
|
use: z.string(),
|
||||||
|
alg: z.string(),
|
||||||
|
kid: z.string(),
|
||||||
|
crv: z.string().optional(),
|
||||||
|
x: z.string().optional(),
|
||||||
|
y: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(meta.allowedMethods, meta.route, async (context) => {
|
app.openapi(route, async (context) => {
|
||||||
const publicKey = await crypto.subtle.importKey(
|
const publicKey = await crypto.subtle.importKey(
|
||||||
"spki",
|
"spki",
|
||||||
Buffer.from(config.oidc.keys?.public ?? "", "base64"),
|
Buffer.from(config.oidc.keys?.public ?? "", "base64"),
|
||||||
|
|
@ -29,15 +58,18 @@ export default apiRoute((app) =>
|
||||||
// Remove the private key
|
// Remove the private key
|
||||||
jwk.d = undefined;
|
jwk.d = undefined;
|
||||||
|
|
||||||
return context.json({
|
return context.json(
|
||||||
keys: [
|
{
|
||||||
{
|
keys: [
|
||||||
...jwk,
|
{
|
||||||
use: "sig",
|
...jwk,
|
||||||
alg: "EdDSA",
|
use: "sig",
|
||||||
kid: "1",
|
alg: "EdDSA",
|
||||||
},
|
kid: "1",
|
||||||
],
|
},
|
||||||
});
|
],
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
import manifest from "~/package.json";
|
import manifest from "~/package.json";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -13,8 +14,45 @@ export const meta = applyConfig({
|
||||||
route: "/.well-known/nodeinfo/2.0",
|
route: "/.well-known/nodeinfo/2.0",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/.well-known/nodeinfo/2.0",
|
||||||
|
summary: "Well-known nodeinfo 2.0",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Nodeinfo 2.0",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
version: z.string(),
|
||||||
|
software: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
}),
|
||||||
|
protocols: z.array(z.string()),
|
||||||
|
services: z.object({
|
||||||
|
outbound: z.array(z.string()),
|
||||||
|
inbound: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
usage: z.object({
|
||||||
|
users: z.object({
|
||||||
|
total: z.number(),
|
||||||
|
activeMonth: z.number(),
|
||||||
|
activeHalfyear: z.number(),
|
||||||
|
}),
|
||||||
|
localPosts: z.number(),
|
||||||
|
}),
|
||||||
|
openRegistrations: z.boolean(),
|
||||||
|
metadata: z.object({}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(meta.allowedMethods, meta.route, (context) => {
|
app.openapi(route, (context) => {
|
||||||
return context.json({
|
return context.json({
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
software: { name: "versia-server", version: manifest.version },
|
software: { name: "versia-server", version: manifest.version },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -13,8 +14,19 @@ export const meta = applyConfig({
|
||||||
route: "/.well-known/nodeinfo",
|
route: "/.well-known/nodeinfo",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/.well-known/nodeinfo",
|
||||||
|
summary: "Well-known nodeinfo",
|
||||||
|
responses: {
|
||||||
|
301: {
|
||||||
|
description: "Redirect to 2.0 Nodeinfo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(meta.allowedMethods, meta.route, (context) => {
|
app.openapi(route, (context) => {
|
||||||
return context.redirect(
|
return context.redirect(
|
||||||
new URL(
|
new URL(
|
||||||
"/.well-known/nodeinfo/2.0",
|
"/.well-known/nodeinfo/2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
|
|
@ -13,21 +14,56 @@ export const meta = applyConfig({
|
||||||
route: "/.well-known/openid-configuration",
|
route: "/.well-known/openid-configuration",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/.well-known/openid-configuration",
|
||||||
|
summary: "OpenID Configuration",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "OpenID Configuration",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
issuer: z.string(),
|
||||||
|
authorization_endpoint: z.string(),
|
||||||
|
token_endpoint: z.string(),
|
||||||
|
userinfo_endpoint: z.string(),
|
||||||
|
jwks_uri: z.string(),
|
||||||
|
response_types_supported: z.array(z.string()),
|
||||||
|
subject_types_supported: z.array(z.string()),
|
||||||
|
id_token_signing_alg_values_supported: z.array(
|
||||||
|
z.string(),
|
||||||
|
),
|
||||||
|
scopes_supported: z.array(z.string()),
|
||||||
|
token_endpoint_auth_methods_supported: z.array(
|
||||||
|
z.string(),
|
||||||
|
),
|
||||||
|
claims_supported: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(meta.allowedMethods, meta.route, (context) => {
|
app.openapi(route, (context) => {
|
||||||
const baseUrl = new URL(config.http.base_url);
|
const baseUrl = new URL(config.http.base_url);
|
||||||
return context.json({
|
return context.json(
|
||||||
issuer: baseUrl.origin.toString(),
|
{
|
||||||
authorization_endpoint: `${baseUrl.origin}/oauth/authorize`,
|
issuer: baseUrl.origin.toString(),
|
||||||
token_endpoint: `${baseUrl.origin}/oauth/token`,
|
authorization_endpoint: `${baseUrl.origin}/oauth/authorize`,
|
||||||
userinfo_endpoint: `${baseUrl.origin}/api/v1/accounts/verify_credentials`,
|
token_endpoint: `${baseUrl.origin}/oauth/token`,
|
||||||
jwks_uri: `${baseUrl.origin}/.well-known/jwks`,
|
userinfo_endpoint: `${baseUrl.origin}/api/v1/accounts/verify_credentials`,
|
||||||
response_types_supported: ["code"],
|
jwks_uri: `${baseUrl.origin}/.well-known/jwks`,
|
||||||
subject_types_supported: ["public"],
|
response_types_supported: ["code"],
|
||||||
id_token_signing_alg_values_supported: ["EdDSA"],
|
subject_types_supported: ["public"],
|
||||||
scopes_supported: ["openid", "profile", "email"],
|
id_token_signing_alg_values_supported: ["EdDSA"],
|
||||||
token_endpoint_auth_methods_supported: ["client_secret_basic"],
|
scopes_supported: ["openid", "profile", "email"],
|
||||||
claims_supported: ["sub"],
|
token_endpoint_auth_methods_supported: ["client_secret_basic"],
|
||||||
});
|
claims_supported: ["sub"],
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
import { apiRoute, applyConfig } from "@/api";
|
||||||
import { urlToContentFormat } from "@/content_types";
|
import { urlToContentFormat } from "@/content_types";
|
||||||
import type { InstanceMetadata } from "@versia/federation/types";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
|
import { InstanceMetadata as InstanceMetadataSchema } from "@versia/federation/schemas";
|
||||||
|
import { asc } from "drizzle-orm";
|
||||||
|
import { Users } from "~/drizzle/schema";
|
||||||
import pkg from "~/package.json";
|
import pkg from "~/package.json";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
|
import { User } from "~/packages/database-interface/user";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -16,28 +20,52 @@ export const meta = applyConfig({
|
||||||
route: "/.well-known/versia",
|
route: "/.well-known/versia",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/.well-known/versia",
|
||||||
|
summary: "Get instance metadata",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Instance metadata",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: InstanceMetadataSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(meta.allowedMethods, meta.route, (context) => {
|
app.openapi(route, async (context) => {
|
||||||
return context.json({
|
// Get date of first user creation
|
||||||
type: "InstanceMetadata",
|
const firstUser = await User.fromSql(undefined, asc(Users.createdAt));
|
||||||
compatibility: {
|
|
||||||
extensions: ["pub.versia:custom_emojis"],
|
return context.json(
|
||||||
versions: ["0.4.0"],
|
{
|
||||||
|
type: "InstanceMetadata" as const,
|
||||||
|
compatibility: {
|
||||||
|
extensions: ["pub.versia:custom_emojis"],
|
||||||
|
versions: ["0.4.0"],
|
||||||
|
},
|
||||||
|
host: new URL(config.http.base_url).host,
|
||||||
|
name: config.instance.name,
|
||||||
|
description: config.instance.description,
|
||||||
|
public_key: {
|
||||||
|
key: config.instance.keys.public,
|
||||||
|
algorithm: "ed25519" as const,
|
||||||
|
},
|
||||||
|
software: {
|
||||||
|
name: "Versia Server",
|
||||||
|
version: pkg.version,
|
||||||
|
},
|
||||||
|
banner: urlToContentFormat(config.instance.banner),
|
||||||
|
logo: urlToContentFormat(config.instance.logo),
|
||||||
|
created_at: new Date(
|
||||||
|
firstUser?.data.createdAt ?? 0,
|
||||||
|
).toISOString(),
|
||||||
},
|
},
|
||||||
host: new URL(config.http.base_url).host,
|
200,
|
||||||
name: config.instance.name,
|
);
|
||||||
description: config.instance.description,
|
|
||||||
public_key: {
|
|
||||||
key: config.instance.keys.public,
|
|
||||||
algorithm: "ed25519",
|
|
||||||
},
|
|
||||||
software: {
|
|
||||||
name: "Versia Server",
|
|
||||||
version: pkg.version,
|
|
||||||
},
|
|
||||||
banner: urlToContentFormat(config.instance.banner),
|
|
||||||
logo: urlToContentFormat(config.instance.logo),
|
|
||||||
created_at: "2021-10-01T00:00:00Z",
|
|
||||||
} satisfies InstanceMetadata);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import { apiRoute, applyConfig, idValidator, webfingerMention } from "@/api";
|
||||||
apiRoute,
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
applyConfig,
|
|
||||||
handleZodError,
|
|
||||||
idValidator,
|
|
||||||
webfingerMention,
|
|
||||||
} from "@/api";
|
|
||||||
import { zValidator } from "@hono/zod-validator";
|
|
||||||
import { getLogger } from "@logtape/logtape";
|
import { getLogger } from "@logtape/logtape";
|
||||||
import type { ResponseError } from "@versia/federation";
|
import type { ResponseError } from "@versia/federation";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
|
@ -14,6 +8,7 @@ import { z } from "zod";
|
||||||
import { Users } from "~/drizzle/schema";
|
import { Users } from "~/drizzle/schema";
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
import { User } from "~/packages/database-interface/user";
|
import { User } from "~/packages/database-interface/user";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -29,86 +24,118 @@ export const meta = applyConfig({
|
||||||
|
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
query: z.object({
|
query: z.object({
|
||||||
resource: z.string().trim().min(1).max(512).startsWith("acct:"),
|
resource: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(512)
|
||||||
|
.startsWith("acct:")
|
||||||
|
.regex(
|
||||||
|
webfingerMention,
|
||||||
|
"Invalid resource (should be acct:(id or username)@domain)",
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/.well-known/webfinger",
|
||||||
|
summary: "Get user information",
|
||||||
|
request: {
|
||||||
|
query: schemas.query,
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "User information",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
subject: z.string(),
|
||||||
|
links: z.array(
|
||||||
|
z.object({
|
||||||
|
rel: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
href: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "User not found",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.on(
|
app.openapi(route, async (context) => {
|
||||||
meta.allowedMethods,
|
const { resource } = context.req.valid("query");
|
||||||
meta.route,
|
|
||||||
zValidator("query", schemas.query, handleZodError),
|
|
||||||
async (context) => {
|
|
||||||
const { resource } = context.req.valid("query");
|
|
||||||
|
|
||||||
// Check if resource is in the correct format (acct:uuid/username@domain)
|
const requestedUser = resource.split("acct:")[1];
|
||||||
if (!resource.match(webfingerMention)) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "Invalid resource (should be acct:(id or username)@domain)",
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedUser = resource.split("acct:")[1];
|
const host = new URL(config.http.base_url).host;
|
||||||
|
|
||||||
const host = new URL(config.http.base_url).host;
|
// Check if user is a local user
|
||||||
|
if (requestedUser.split("@")[1] !== host) {
|
||||||
|
return context.json({ error: "User is a remote user" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user is a local user
|
const isUuid = requestedUser.split("@")[0].match(idValidator);
|
||||||
if (requestedUser.split("@")[1] !== host) {
|
|
||||||
return context.json({ error: "User is a remote user" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isUuid = requestedUser.split("@")[0].match(idValidator);
|
const user = await User.fromSql(
|
||||||
|
and(
|
||||||
const user = await User.fromSql(
|
eq(
|
||||||
and(
|
isUuid ? Users.id : Users.username,
|
||||||
eq(
|
requestedUser.split("@")[0],
|
||||||
isUuid ? Users.id : Users.username,
|
|
||||||
requestedUser.split("@")[0],
|
|
||||||
),
|
|
||||||
isNull(Users.instanceId),
|
|
||||||
),
|
),
|
||||||
);
|
isNull(Users.instanceId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return context.json({ error: "User not found" }, 404);
|
return context.json({ error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let activityPubUrl = "";
|
||||||
|
|
||||||
|
if (config.federation.bridge.enabled) {
|
||||||
|
const manager = await User.getFederationRequester();
|
||||||
|
|
||||||
|
try {
|
||||||
|
activityPubUrl = await manager.webFinger(
|
||||||
|
user.data.username,
|
||||||
|
new URL(config.http.base_url).host,
|
||||||
|
"application/activity+json",
|
||||||
|
config.federation.bridge.url,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as ResponseError;
|
||||||
|
|
||||||
|
getLogger("federation")
|
||||||
|
.error`Error from bridge: ${await error.response.data}`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let activityPubUrl = "";
|
return context.json(
|
||||||
|
{
|
||||||
if (config.federation.bridge.enabled) {
|
subject: `acct:${isUuid ? user.id : user.data.username}@${host}`,
|
||||||
const manager = await User.getFederationRequester();
|
|
||||||
|
|
||||||
try {
|
|
||||||
activityPubUrl = await manager.webFinger(
|
|
||||||
user.data.username,
|
|
||||||
new URL(config.http.base_url).host,
|
|
||||||
"application/activity+json",
|
|
||||||
config.federation.bridge.url,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as ResponseError;
|
|
||||||
|
|
||||||
getLogger("federation")
|
|
||||||
.error`Error from bridge: ${await error.response.data}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
subject: `acct:${
|
|
||||||
isUuid ? user.id : user.data.username
|
|
||||||
}@${host}`,
|
|
||||||
|
|
||||||
links: [
|
links: [
|
||||||
// Keep the ActivityPub link first, because Misskey only searches
|
// Keep the ActivityPub link first, because Misskey only searches
|
||||||
// for the first link with rel="self" and doesn't check the type.
|
// for the first link with rel="self" and doesn't check the type.
|
||||||
activityPubUrl && {
|
activityPubUrl
|
||||||
rel: "self",
|
? {
|
||||||
type: "application/activity+json",
|
rel: "self",
|
||||||
href: activityPubUrl,
|
type: "application/activity+json",
|
||||||
},
|
href: activityPubUrl,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
{
|
{
|
||||||
rel: "self",
|
rel: "self",
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
|
|
@ -119,11 +146,18 @@ export default apiRoute((app) =>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rel: "avatar",
|
rel: "avatar",
|
||||||
type: lookup(user.getAvatarUrl(config)),
|
type:
|
||||||
|
lookup(user.getAvatarUrl(config)) ??
|
||||||
|
"application/octet-stream",
|
||||||
href: user.getAvatarUrl(config),
|
href: user.getAvatarUrl(config),
|
||||||
},
|
},
|
||||||
].filter(Boolean),
|
].filter(Boolean) as {
|
||||||
});
|
rel: string;
|
||||||
},
|
type: string;
|
||||||
),
|
href: string;
|
||||||
|
}[],
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
1
drizzle/migrations/0033_panoramic_sister_grimm.sql
Normal file
1
drizzle/migrations/0033_panoramic_sister_grimm.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "Filters" ALTER COLUMN "context" SET NOT NULL;
|
||||||
2126
drizzle/migrations/meta/0033_snapshot.json
Normal file
2126
drizzle/migrations/meta/0033_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -232,6 +232,13 @@
|
||||||
"when": 1724073118382,
|
"when": 1724073118382,
|
||||||
"tag": "0032_ambiguous_sue_storm",
|
"tag": "0032_ambiguous_sue_storm",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 33,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1726491670160,
|
||||||
|
"tag": "0033_panoramic_sister_grimm",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export const Filters = pgTable("Filters", {
|
||||||
}),
|
}),
|
||||||
context: text("context")
|
context: text("context")
|
||||||
.array()
|
.array()
|
||||||
|
.notNull()
|
||||||
.$type<
|
.$type<
|
||||||
("home" | "notifications" | "public" | "thread" | "account")[]
|
("home" | "notifications" | "public" | "thread" | "account")[]
|
||||||
>(),
|
>(),
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@
|
||||||
"@sentry/bun": "^8.30.0",
|
"@sentry/bun": "^8.30.0",
|
||||||
"@tufjs/canonical-json": "^2.0.0",
|
"@tufjs/canonical-json": "^2.0.0",
|
||||||
"@versia/client": "^0.1.0",
|
"@versia/client": "^0.1.0",
|
||||||
"@versia/federation": "^0.1.0",
|
"@versia/federation": "^0.1.1-rc.0",
|
||||||
"altcha-lib": "^1.1.0",
|
"altcha-lib": "^1.1.0",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"bullmq": "^5.13.0",
|
"bullmq": "^5.13.0",
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ describe("API Tests", () => {
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(202);
|
expect(response.status).toBe(200);
|
||||||
expect(response.headers.get("content-type")).toContain(
|
expect(response.headers.get("content-type")).toContain(
|
||||||
"application/json",
|
"application/json",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
Unfollow,
|
Unfollow,
|
||||||
User,
|
User,
|
||||||
} from "@versia/federation/types";
|
} from "@versia/federation/types";
|
||||||
|
import type { SocketAddress } from "bun";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Application } from "~/classes/functions/application";
|
import type { Application } from "~/classes/functions/application";
|
||||||
import type { RolePermissions } from "~/drizzle/schema";
|
import type { RolePermissions } from "~/drizzle/schema";
|
||||||
|
|
@ -59,6 +60,9 @@ export type HonoEnv = {
|
||||||
application: Application | null;
|
application: Application | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
Bindings: {
|
||||||
|
ip?: SocketAddress | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ApiRouteExports {
|
export interface ApiRouteExports {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue