refactor(api): ♻️ More OpenAPI refactoring work

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

View file

@ -1,13 +1,6 @@
import { 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);
});
});

View file

@ -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,
), );
}),
); );

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
});
});

View file

@ -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,
);
});
});

View file

@ -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); });
}), }),
); );

View file

@ -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);
}),
); );

View file

@ -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,
), );
}),
); );

View file

@ -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;
}),
); );

View file

@ -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;
}),
); );

View file

@ -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(),
);
}),
); );

View file

@ -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()}`,
);
}),
); );

View file

@ -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,
); );
}, }),
),
); );

View file

@ -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());
}),
); );

View file

@ -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,
);
}
}),
); );

View file

@ -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());
}),
); );

View file

@ -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());
}),
); );

View file

@ -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;
}), }),
); );

View file

@ -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,
);
}), }),
); );

View file

@ -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 },

View file

@ -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",

View file

@ -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,
);
}), }),
); );

View file

@ -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);
}), }),
); );

View file

@ -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,
);
}),
); );

BIN
bun.lockb

Binary file not shown.

View file

@ -0,0 +1 @@
ALTER TABLE "Filters" ALTER COLUMN "context" SET NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -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
} }
] ]
} }

View file

@ -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")[]
>(), >(),

View file

@ -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",

View file

@ -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",
); );

View file

@ -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 {