diff --git a/api/api/v1/emojis/:id/index.ts b/api/api/v1/emojis/:id/index.ts index dc4f4a51..454ea84e 100644 --- a/api/api/v1/emojis/:id/index.ts +++ b/api/api/v1/emojis/:id/index.ts @@ -1,13 +1,6 @@ -import { - apiRoute, - applyConfig, - auth, - emojiValidator, - handleZodError, - jsonOrForm, -} from "@/api"; +import { apiRoute, applyConfig, auth, emojiValidator, jsonOrForm } from "@/api"; import { mimeLookup } from "@/content_types"; -import { zValidator } from "@hono/zod-validator"; +import { createRoute } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { MediaManager } from "~/classes/media/media-manager"; @@ -16,6 +9,7 @@ import { Emojis, RolePermissions } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { Attachment } from "~/packages/database-interface/attachment"; import { Emoji } from "~/packages/database-interface/emoji"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["DELETE", "GET", "PATCH"], @@ -62,146 +56,302 @@ export const schemas = { .or(z.boolean()) .optional(), }) - .partial() - .optional(), + .partial(), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - zValidator("param", schemas.param, handleZodError), - zValidator("json", schemas.json, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - const { user } = context.get("auth"); +const routeGet = createRoute({ + method: "get", + path: "/api/v1/emojis/{id}", + summary: "Get emoji data", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Emoji", + content: { + "application/json": { + schema: Emoji.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 403: { + description: "Insufficient credentials", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Emoji not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } +const routePatch = createRoute({ + method: "patch", + path: "/api/v1/emojis/{id}", + summary: "Modify emoji", + middleware: [auth(meta.auth, meta.permissions), jsonOrForm()], + request: { + params: schemas.param, + body: { + content: { + "application/json": { + schema: schemas.json, + }, + "application/x-www-form-urlencoded": { + schema: schemas.json, + }, + "multipart/form-data": { + schema: schemas.json, + }, + }, + }, + }, + responses: { + 200: { + description: "Emoji modified", + content: { + "application/json": { + schema: Emoji.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 403: { + description: "Insufficient credentials", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Emoji not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 422: { + description: "Invalid form data", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); - const emoji = await Emoji.fromId(id); +const routeDelete = createRoute({ + method: "delete", + path: "/api/v1/emojis/{id}", + summary: "Delete emoji", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 204: { + description: "Emoji deleted", + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Emoji not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); - if (!emoji) { - return context.json({ error: "Emoji not found" }, 404); - } +export default apiRoute((app) => { + app.openapi(routeGet, async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.get("auth"); - // Check if user is admin - if ( - !user.hasPermission(RolePermissions.ManageEmojis) && - emoji.data.ownerId !== user.data.id - ) { + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const emoji = await Emoji.fromId(id); + + if (!emoji) { + return context.json({ error: "Emoji not found" }, 404); + } + + // Check if user is admin + if ( + !user.hasPermission(RolePermissions.ManageEmojis) && + emoji.data.ownerId !== user.data.id + ) { + return context.json( + { + error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`, + }, + 403, + ); + } + + return context.json(emoji.toApi(), 200); + }); + + app.openapi(routePatch, async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const emoji = await Emoji.fromId(id); + + if (!emoji) { + return context.json({ error: "Emoji not found" }, 404); + } + + // Check if user is admin + if ( + !user.hasPermission(RolePermissions.ManageEmojis) && + emoji.data.ownerId !== user.data.id + ) { + return context.json( + { + error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`, + }, + 403, + ); + } + + const mediaManager = new MediaManager(config); + + const { + global: emojiGlobal, + alt, + category, + element, + shortcode, + } = context.req.valid("json"); + + if (!user.hasPermission(RolePermissions.ManageEmojis) && emojiGlobal) { + return context.json( + { + error: `Only users with the '${RolePermissions.ManageEmojis}' permission can make an emoji global or not`, + }, + 401, + ); + } + + const modified = structuredClone(emoji.data); + + if (element) { + // Check of emoji is an image + let contentType = + element instanceof File + ? element.type + : await mimeLookup(element); + + if (!contentType.startsWith("image/")) { return context.json( { - error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`, + error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, }, - 403, + 422, ); } - const mediaManager = new MediaManager(config); + let url = ""; - switch (context.req.method) { - case "DELETE": { - await mediaManager.deleteFileByUrl(emoji.data.url); + if (element instanceof File) { + const uploaded = await mediaManager.addFile(element); - await db.delete(Emojis).where(eq(Emojis.id, id)); - - return context.newResponse(null, 204); - } - - case "PATCH": { - const form = context.req.valid("json"); - - if (!form) { - return context.json( - { - error: "Invalid form data (must supply at least one of: shortcode, element, alt, category)", - }, - 422, - ); - } - - if ( - !( - form.shortcode || - form.element || - form.alt || - form.category - ) && - form.global === undefined - ) { - return context.json( - { - error: "Invalid form data (must supply at least one of: shortcode, element, alt, category)", - }, - 422, - ); - } - - if ( - !user.hasPermission(RolePermissions.ManageEmojis) && - form.global - ) { - return context.json( - { - error: `Only users with the '${RolePermissions.ManageEmojis}' permission can make an emoji global or not`, - }, - 401, - ); - } - - const modified = structuredClone(emoji.data); - - if (form.element) { - // Check of emoji is an image - let contentType = - form.element instanceof File - ? form.element.type - : await mimeLookup(form.element); - - if (!contentType.startsWith("image/")) { - return context.json( - { - error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, - }, - 422, - ); - } - - let url = ""; - - if (form.element instanceof File) { - const uploaded = await mediaManager.addFile( - form.element, - ); - - url = uploaded.path; - contentType = uploaded.uploadedFile.type; - } else { - url = form.element; - } - - modified.url = Attachment.getUrl(url); - modified.contentType = contentType; - } - - modified.shortcode = form.shortcode ?? modified.shortcode; - modified.alt = form.alt ?? modified.alt; - modified.category = form.category ?? modified.category; - modified.ownerId = form.global ? null : user.data.id; - - await emoji.update(modified); - - return context.json(emoji.toApi()); - } - - case "GET": { - return context.json(emoji.toApi()); - } + url = uploaded.path; + contentType = uploaded.uploadedFile.type; + } else { + url = element; } - }, - ), -); + + modified.url = Attachment.getUrl(url); + modified.contentType = contentType; + } + + modified.shortcode = shortcode ?? modified.shortcode; + modified.alt = alt ?? modified.alt; + modified.category = category ?? modified.category; + modified.ownerId = emojiGlobal ? null : user.data.id; + + await emoji.update(modified); + + return context.json(emoji.toApi(), 200); + }); + + app.openapi(routeDelete, async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const emoji = await Emoji.fromId(id); + + if (!emoji) { + return context.json({ error: "Emoji not found" }, 404); + } + + // Check if user is admin + if ( + !user.hasPermission(RolePermissions.ManageEmojis) && + emoji.data.ownerId !== user.data.id + ) { + return context.json( + { + error: `You cannot delete this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`, + }, + 403, + ); + } + + const mediaManager = new MediaManager(config); + + await mediaManager.deleteFileByUrl(emoji.data.url); + + await db.delete(Emojis).where(eq(Emojis.id, id)); + + return context.newResponse(null, 204); + }); +}); diff --git a/api/api/v1/instance/extended_description.ts b/api/api/v1/instance/extended_description.ts index 03e4b513..e7513e80 100644 --- a/api/api/v1/instance/extended_description.ts +++ b/api/api/v1/instance/extended_description.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig, auth } from "@/api"; +import { apiRoute, applyConfig } from "@/api"; import { renderMarkdownInPath } from "@/markdown"; +import { createRoute, z } from "@hono/zod-openapi"; import { config } from "~/packages/config-manager"; export const meta = applyConfig({ @@ -14,21 +15,38 @@ export const meta = applyConfig({ }, }); -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - auth(meta.auth, meta.permissions), - async (context) => { - const { content, lastModified } = await renderMarkdownInPath( - config.instance.extended_description_path ?? "", - "This is a [Versia](https://versia.pub) server with the default extended description.", - ); +const route = createRoute({ + method: "get", + path: "/api/v1/instance/extended_description", + summary: "Get extended description", + responses: { + 200: { + description: "Extended description", + content: { + "application/json": { + schema: z.object({ + updated_at: z.string(), + content: z.string(), + }), + }, + }, + }, + }, +}); - return context.json({ +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { content, lastModified } = await renderMarkdownInPath( + config.instance.extended_description_path ?? "", + "This is a [Versia](https://versia.pub) server with the default extended description.", + ); + + return context.json( + { updated_at: lastModified.toISOString(), content, - }); - }, - ), + }, + 200, + ); + }), ); diff --git a/api/api/v1/notifications/index.test.ts b/api/api/v1/notifications/index.test.ts index 65491fcb..665dad2c 100644 --- a/api/api/v1/notifications/index.test.ts +++ b/api/api/v1/notifications/index.test.ts @@ -160,6 +160,6 @@ describe(meta.route, () => { }, ); - expect(filterDeleteResponse.status).toBe(200); + expect(filterDeleteResponse.status).toBe(204); }); }); diff --git a/api/api/v1/timelines/home.test.ts b/api/api/v1/timelines/home.test.ts index 02f17a2d..d030af90 100644 --- a/api/api/v1/timelines/home.test.ts +++ b/api/api/v1/timelines/home.test.ts @@ -190,7 +190,7 @@ describe(meta.route, () => { }, ); - expect(filterDeleteResponse.status).toBe(200); + expect(filterDeleteResponse.status).toBe(204); }); }); }); diff --git a/api/api/v1/timelines/public.test.ts b/api/api/v1/timelines/public.test.ts index 1d87fb9e..90a451fc 100644 --- a/api/api/v1/timelines/public.test.ts +++ b/api/api/v1/timelines/public.test.ts @@ -234,6 +234,6 @@ describe(meta.route, () => { }, ); - expect(filterDeleteResponse.status).toBe(200); + expect(filterDeleteResponse.status).toBe(204); }); }); diff --git a/api/api/v2/filters/:id/index.test.ts b/api/api/v2/filters/:id/index.test.ts index 790ff8a2..d416780e 100644 --- a/api/api/v2/filters/:id/index.test.ts +++ b/api/api/v2/filters/:id/index.test.ts @@ -154,7 +154,7 @@ describe(meta.route, () => { }, ); - expect(response.status).toBe(200); + expect(response.status).toBe(204); // Try to GET the filter again const getResponse = await fakeRequest( diff --git a/api/api/v2/filters/:id/index.ts b/api/api/v2/filters/:id/index.ts index b68a2127..84e741cd 100644 --- a/api/api/v2/filters/:id/index.ts +++ b/api/api/v2/filters/:id/index.ts @@ -1,9 +1,10 @@ -import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "~/drizzle/db"; import { FilterKeywords, Filters, RolePermissions } from "~/drizzle/schema"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET", "PUT", "DELETE"], @@ -68,159 +69,281 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - zValidator("param", schemas.param, handleZodError), - zValidator("json", schemas.json, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { user } = context.get("auth"); - const { id } = context.req.valid("param"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const userFilter = await db.query.Filters.findFirst({ - where: (filter, { eq, and }) => - and(eq(filter.userId, user.id), eq(filter.id, id)), - with: { - keywords: true, - }, - }); - - if (!userFilter) { - return context.json({ error: "Filter not found" }, 404); - } - - switch (context.req.method) { - case "GET": { - return context.json({ - id: userFilter.id, - title: userFilter.title, - context: userFilter.context, - expires_at: userFilter.expireAt - ? new Date(userFilter.expireAt).toISOString() - : null, - filter_action: userFilter.filterAction, - keywords: userFilter.keywords.map((keyword) => ({ - id: keyword.id, - keyword: keyword.keyword, - whole_word: keyword.wholeWord, - })), - statuses: [], - }); - } - case "PUT": { - const { - title, - context: ctx, - filter_action, - expires_in, - keywords_attributes, - } = context.req.valid("json"); - - await db - .update(Filters) - .set({ - title, - context: ctx ?? [], - filterAction: filter_action, - expireAt: new Date( - Date.now() + (expires_in ?? 0), - ).toISOString(), - }) - .where( - and( - eq(Filters.userId, user.id), - eq(Filters.id, id), - ), - ); - - const toUpdate = keywords_attributes - ?.filter((keyword) => keyword.id && !keyword._destroy) - .map((keyword) => ({ - keyword: keyword.keyword, - wholeWord: keyword.whole_word ?? false, - id: keyword.id, - })); - - const toDelete = keywords_attributes - ?.filter((keyword) => keyword._destroy && keyword.id) - .map((keyword) => keyword.id ?? ""); - - if (toUpdate && toUpdate.length > 0) { - for (const keyword of toUpdate) { - await db - .update(FilterKeywords) - .set(keyword) - .where( - and( - eq(FilterKeywords.filterId, id), - eq(FilterKeywords.id, keyword.id ?? ""), - ), - ); - } - } - - if (toDelete && toDelete.length > 0) { - await db - .delete(FilterKeywords) - .where( - and( - eq(FilterKeywords.filterId, id), - inArray(FilterKeywords.id, toDelete), - ), - ); - } - - const updatedFilter = await db.query.Filters.findFirst({ - where: (filter, { eq, and }) => - and(eq(filter.userId, user.id), eq(filter.id, id)), - with: { - keywords: true, - }, - }); - - if (!updatedFilter) { - return context.json( - { error: "Failed to update filter" }, - 500, - ); - } - - return context.json({ - id: updatedFilter.id, - title: updatedFilter.title, - context: updatedFilter.context, - expires_at: updatedFilter.expireAt - ? new Date(updatedFilter.expireAt).toISOString() - : null, - filter_action: updatedFilter.filterAction, - keywords: updatedFilter.keywords.map((keyword) => ({ - id: keyword.id, - keyword: keyword.keyword, - whole_word: keyword.wholeWord, - })), - statuses: [], - }); - } - case "DELETE": { - await db - .delete(Filters) - .where( - and( - eq(Filters.userId, user.id), - eq(Filters.id, id), - ), - ); - - return context.json({}); - } - } - }, +const filterSchema = z.object({ + id: z.string(), + title: z.string(), + context: z.array(z.string()), + expires_at: z.string().nullable(), + filter_action: z.enum(["warn", "hide"]), + keywords: z.array( + z.object({ + id: z.string(), + keyword: z.string(), + whole_word: z.boolean(), + }), ), -); + statuses: z.array(z.string()), +}); + +const routeGet = createRoute({ + method: "get", + path: "/api/v2/filters/{id}", + summary: "Get filter", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Filter", + content: { + "application/json": { + schema: filterSchema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Filter not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routePut = createRoute({ + method: "put", + path: "/api/v2/filters/{id}", + summary: "Update filter", + middleware: [auth(meta.auth, meta.permissions), jsonOrForm()], + request: { + params: schemas.param, + body: { + content: { + "application/json": { + schema: schemas.json, + }, + }, + }, + }, + responses: { + 200: { + description: "Filter updated", + content: { + "application/json": { + schema: filterSchema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Filter not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routeDelete = createRoute({ + method: "delete", + path: "/api/v2/filters/{id}", + summary: "Delete filter", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 204: { + description: "Filter deleted", + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Filter not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => { + app.openapi(routeGet, async (context) => { + const { user } = context.get("auth"); + const { id } = context.req.valid("param"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const userFilter = await db.query.Filters.findFirst({ + where: (filter, { eq, and }) => + and(eq(filter.userId, user.id), eq(filter.id, id)), + with: { + keywords: true, + }, + }); + + if (!userFilter) { + return context.json({ error: "Filter not found" }, 404); + } + + return context.json( + { + id: userFilter.id, + title: userFilter.title, + context: userFilter.context, + expires_at: userFilter.expireAt + ? new Date(userFilter.expireAt).toISOString() + : null, + filter_action: userFilter.filterAction, + keywords: userFilter.keywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + }, + 200, + ); + }); + + app.openapi(routePut, async (context) => { + const { user } = context.get("auth"); + const { id } = context.req.valid("param"); + const { + title, + context: ctx, + filter_action, + expires_in, + keywords_attributes, + } = context.req.valid("json"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + await db + .update(Filters) + .set({ + title, + context: ctx ?? [], + filterAction: filter_action, + expireAt: new Date( + Date.now() + (expires_in ?? 0), + ).toISOString(), + }) + .where(and(eq(Filters.userId, user.id), eq(Filters.id, id))); + + const toUpdate = keywords_attributes + ?.filter((keyword) => keyword.id && !keyword._destroy) + .map((keyword) => ({ + keyword: keyword.keyword, + wholeWord: keyword.whole_word ?? false, + id: keyword.id, + })); + + const toDelete = keywords_attributes + ?.filter((keyword) => keyword._destroy && keyword.id) + .map((keyword) => keyword.id ?? ""); + + if (toUpdate && toUpdate.length > 0) { + for (const keyword of toUpdate) { + await db + .update(FilterKeywords) + .set(keyword) + .where( + and( + eq(FilterKeywords.filterId, id), + eq(FilterKeywords.id, keyword.id ?? ""), + ), + ); + } + } + + if (toDelete && toDelete.length > 0) { + await db + .delete(FilterKeywords) + .where( + and( + eq(FilterKeywords.filterId, id), + inArray(FilterKeywords.id, toDelete), + ), + ); + } + + const updatedFilter = await db.query.Filters.findFirst({ + where: (filter, { eq, and }) => + and(eq(filter.userId, user.id), eq(filter.id, id)), + with: { + keywords: true, + }, + }); + + if (!updatedFilter) { + throw new Error("Failed to update filter"); + } + + return context.json( + { + id: updatedFilter.id, + title: updatedFilter.title, + context: updatedFilter.context, + expires_at: updatedFilter.expireAt + ? new Date(updatedFilter.expireAt).toISOString() + : null, + filter_action: updatedFilter.filterAction, + keywords: updatedFilter.keywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + }, + 200, + ); + }); + + app.openapi(routeDelete, async (context) => { + const { user } = context.get("auth"); + const { id } = context.req.valid("param"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + await db + .delete(Filters) + .where(and(eq(Filters.userId, user.id), eq(Filters.id, id))); + + return context.newResponse(null, 204); + }); +}); diff --git a/api/api/v2/filters/index.ts b/api/api/v2/filters/index.ts index c062e988..dcea1058 100644 --- a/api/api/v2/filters/index.ts +++ b/api/api/v2/filters/index.ts @@ -1,8 +1,9 @@ -import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { z } from "zod"; import { db } from "~/drizzle/db"; import { FilterKeywords, Filters, RolePermissions } from "~/drizzle/schema"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET", "POST"], route: "/api/v2/filters", @@ -19,178 +20,228 @@ export const meta = applyConfig({ }); export const schemas = { - json: z - .object({ - title: z.string().trim().min(1).max(100).optional(), - context: z - .array( - z.enum([ - "home", - "notifications", - "public", - "thread", - "account", - ]), - ) - .optional(), - filter_action: z.enum(["warn", "hide"]).optional().default("warn"), - expires_in: z.coerce - .number() - .int() - .min(60) - .max(60 * 60 * 24 * 365 * 5) - .optional(), - keywords_attributes: z - .array( - z.object({ - keyword: z.string().trim().min(1).max(100), - whole_word: z - .string() - .transform((v) => - ["true", "1", "on"].includes(v.toLowerCase()), - ) - .optional(), - }), - ) - .optional(), - }) - .optional(), + json: z.object({ + title: z.string().trim().min(1).max(100), + context: z + .array( + z.enum([ + "home", + "notifications", + "public", + "thread", + "account", + ]), + ) + .min(1), + filter_action: z.enum(["warn", "hide"]).optional().default("warn"), + expires_in: z.coerce + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .optional(), + keywords_attributes: z + .array( + z.object({ + keyword: z.string().trim().min(1).max(100), + whole_word: z + .string() + .transform((v) => + ["true", "1", "on"].includes(v.toLowerCase()), + ) + .optional(), + }), + ) + .optional(), + }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - zValidator("json", schemas.json, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - switch (context.req.method) { - case "GET": { - const userFilters = await db.query.Filters.findMany({ - where: (filter, { eq }) => eq(filter.userId, user.id), - with: { - keywords: true, - }, - }); - - return context.json( - userFilters.map((filter) => ({ - id: filter.id, - title: filter.title, - context: filter.context, - expires_at: filter.expireAt - ? new Date( - Date.now() + filter.expireAt, - ).toISOString() - : null, - filter_action: filter.filterAction, - keywords: filter.keywords.map((keyword) => ({ - id: keyword.id, - keyword: keyword.keyword, - whole_word: keyword.wholeWord, - })), - statuses: [], - })), - ); - } - case "POST": { - const form = context.req.valid("json"); - if (!form) { - return context.json( - { error: "Missing required fields" }, - 422, - ); - } - - const { - title, - context: ctx, - filter_action, - expires_in, - keywords_attributes, - } = form; - - if (!title || ctx?.length === 0) { - return context.json( - { - error: "Missing required fields (title and context)", - }, - 422, - ); - } - - const newFilter = ( - await db - .insert(Filters) - .values({ - title: title ?? "", - context: ctx ?? [], - filterAction: filter_action, - expireAt: new Date( - Date.now() + (expires_in ?? 0), - ).toISOString(), - userId: user.id, - }) - .returning() - )[0]; - - if (!newFilter) { - return context.json( - { error: "Failed to create filter" }, - 500, - ); - } - - const insertedKeywords = - keywords_attributes && keywords_attributes.length > 0 - ? await db - .insert(FilterKeywords) - .values( - keywords_attributes?.map((keyword) => ({ - filterId: newFilter.id, - keyword: keyword.keyword, - wholeWord: - keyword.whole_word ?? false, - })) ?? [], - ) - .returning() - : []; - - return context.json({ - id: newFilter.id, - title: newFilter.title, - context: newFilter.context, - expires_at: expires_in - ? new Date(Date.now() + expires_in).toISOString() - : null, - filter_action: newFilter.filterAction, - keywords: insertedKeywords.map((keyword) => ({ - id: keyword.id, - keyword: keyword.keyword, - whole_word: keyword.wholeWord, - })), - statuses: [], - } as { - id: string; - title: string; - context: string[]; - expires_at: string; - filter_action: "warn" | "hide"; - keywords: { - id: string; - keyword: string; - whole_word: boolean; - }[]; - statuses: []; - }); - } - } - }, +const filterSchema = z.object({ + id: z.string(), + title: z.string(), + context: z.array(z.string()), + expires_at: z.string().nullable(), + filter_action: z.enum(["warn", "hide"]), + keywords: z.array( + z.object({ + id: z.string(), + keyword: z.string(), + whole_word: z.boolean(), + }), ), -); + statuses: z.array(z.string()), +}); + +const routeGet = createRoute({ + method: "get", + path: "/api/v2/filters", + summary: "Get filters", + middleware: [auth(meta.auth, meta.permissions), jsonOrForm()], + responses: { + 200: { + description: "Filters", + content: { + "application/json": { + schema: z.array(filterSchema), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routePost = createRoute({ + method: "post", + path: "/api/v2/filters", + summary: "Create filter", + middleware: [auth(meta.auth, meta.permissions), jsonOrForm()], + request: { + body: { + content: { + "application/json": { + schema: schemas.json, + }, + }, + }, + }, + responses: { + 200: { + description: "Filter created", + content: { + "application/json": { + schema: filterSchema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => { + app.openapi(routeGet, async (context) => { + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const userFilters = await db.query.Filters.findMany({ + where: (filter, { eq }) => eq(filter.userId, user.id), + with: { + keywords: true, + }, + }); + + return context.json( + userFilters.map((filter) => ({ + id: filter.id, + title: filter.title, + context: filter.context, + expires_at: filter.expireAt + ? new Date(Date.now() + filter.expireAt).toISOString() + : null, + filter_action: filter.filterAction, + keywords: filter.keywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + })), + 200, + ); + }); + + app.openapi(routePost, async (context) => { + const { user } = context.get("auth"); + const { + title, + context: ctx, + filter_action, + expires_in, + keywords_attributes, + } = context.req.valid("json"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const newFilter = ( + await db + .insert(Filters) + .values({ + title: title ?? "", + context: ctx ?? [], + filterAction: filter_action, + expireAt: new Date( + Date.now() + (expires_in ?? 0), + ).toISOString(), + userId: user.id, + }) + .returning() + )[0]; + + if (!newFilter) { + throw new Error("Failed to create filter"); + } + + const insertedKeywords = + keywords_attributes && keywords_attributes.length > 0 + ? await db + .insert(FilterKeywords) + .values( + keywords_attributes?.map((keyword) => ({ + filterId: newFilter.id, + keyword: keyword.keyword, + wholeWord: keyword.whole_word ?? false, + })) ?? [], + ) + .returning() + : []; + + return context.json( + { + id: newFilter.id, + title: newFilter.title, + context: newFilter.context, + expires_at: expires_in + ? new Date(Date.now() + expires_in).toISOString() + : null, + filter_action: newFilter.filterAction, + keywords: insertedKeywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + } as { + id: string; + title: string; + context: string[]; + expires_at: string; + filter_action: "warn" | "hide"; + keywords: { + id: string; + keyword: string; + whole_word: boolean; + }[]; + statuses: []; + }, + 200, + ); + }); +}); diff --git a/api/api/v2/instance/index.ts b/api/api/v2/instance/index.ts index 7a5a4783..9612f676 100644 --- a/api/api/v2/instance/index.ts +++ b/api/api/v2/instance/index.ts @@ -1,6 +1,6 @@ import { apiRoute, applyConfig } from "@/api"; import { proxyUrl } from "@/response"; -import type { Instance as ApiInstance } from "@versia/client/types"; +import { createRoute, z } from "@hono/zod-openapi"; import { and, eq, isNull } from "drizzle-orm"; import { Users } from "~/drizzle/schema"; import manifest from "~/package.json"; @@ -19,8 +19,110 @@ export const meta = applyConfig({ }, }); +const route = createRoute({ + method: "get", + path: "/api/v2/instance", + summary: "Get instance metadata", + responses: { + 200: { + description: "Instance metadata", + content: { + "application/json": { + schema: z.object({ + domain: z.string(), + title: z.string(), + version: z.string(), + versia_version: z.string(), + source_url: z.string(), + description: z.string(), + usage: z.object({ + users: z.object({ + active_month: z.number(), + }), + }), + thumbnail: z.object({ + url: z.string().nullable(), + }), + banner: z.object({ + url: z.string().nullable(), + }), + languages: z.array(z.string()), + configuration: z.object({ + urls: z.object({ + streaming: z.string().nullable(), + status: z.string().nullable(), + }), + accounts: z.object({ + max_featured_tags: z.number(), + max_displayname_characters: z.number(), + avatar_size_limit: z.number(), + header_size_limit: z.number(), + max_fields_name_characters: z.number(), + max_fields_value_characters: z.number(), + max_fields: z.number(), + max_username_characters: z.number(), + max_note_characters: z.number(), + }), + statuses: z.object({ + max_characters: z.number(), + max_media_attachments: z.number(), + characters_reserved_per_url: z.number(), + }), + media_attachments: z.object({ + supported_mime_types: z.array(z.string()), + image_size_limit: z.number(), + image_matrix_limit: z.number(), + video_size_limit: z.number(), + video_frame_rate_limit: z.number(), + video_matrix_limit: z.number(), + max_description_characters: z.number(), + }), + polls: z.object({ + max_characters_per_option: z.number(), + max_expiration: z.number(), + max_options: z.number(), + min_expiration: z.number(), + }), + translation: z.object({ + enabled: z.boolean(), + }), + }), + registrations: z.object({ + enabled: z.boolean(), + approval_required: z.boolean(), + message: z.string().nullable(), + url: z.string().nullable(), + }), + contact: z.object({ + email: z.string().nullable(), + account: User.schema.nullable(), + }), + rules: z.array( + z.object({ + id: z.string(), + text: z.string(), + hint: z.string(), + }), + ), + sso: z.object({ + forced: z.boolean(), + providers: z.array( + z.object({ + name: z.string(), + icon: z.string(), + id: z.string(), + }), + ), + }), + }), + }, + }, + }, + }, +}); + export default apiRoute((app) => - app.on(meta.allowedMethods, meta.route, async (context) => { + app.openapi(route, async (context) => { // Get software version from package.json const version = manifest.version; @@ -122,6 +224,6 @@ export default apiRoute((app) => id: p.id, })), }, - } satisfies ApiInstance); + }); }), ); diff --git a/api/api/v2/media/index.ts b/api/api/v2/media/index.ts index 6cee7a10..aced5241 100644 --- a/api/api/v2/media/index.ts +++ b/api/api/v2/media/index.ts @@ -1,11 +1,12 @@ -import { apiRoute, applyConfig, auth, handleZodError } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import sharp from "sharp"; import { z } from "zod"; import { MediaManager } from "~/classes/media/media-manager"; import { RolePermissions } from "~/drizzle/schema"; import { config } from "~/packages/config-manager/index"; import { Attachment } from "~/packages/database-interface/attachment"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -35,78 +36,104 @@ export const schemas = { }), }; +const route = createRoute({ + method: "post", + path: "/api/v2/media", + summary: "Upload media", + middleware: [auth(meta.auth, meta.permissions)], + request: { + body: { + content: { + "multipart/form-data": { + schema: schemas.form, + }, + }, + }, + }, + responses: { + 200: { + description: "Uploaded media", + content: { + "application/json": { + schema: Attachment.schema, + }, + }, + }, + 413: { + description: "Payload too large", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 415: { + description: "Unsupported media type", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("form", schemas.form, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { file, thumbnail, description } = context.req.valid("form"); - - if (file.size > config.validation.max_media_size) { - return context.json( - { - error: `File too large, max size is ${config.validation.max_media_size} bytes`, - }, - 413, - ); - } - - if ( - config.validation.enforce_mime_types && - !config.validation.allowed_mime_types.includes(file.type) - ) { - return context.json({ error: "Invalid file type" }, 415); - } - - const sha256 = new Bun.SHA256(); - - const isImage = file.type.startsWith("image/"); - - const metadata = isImage - ? await sharp(await file.arrayBuffer()).metadata() - : null; - - const mediaManager = new MediaManager(config); - - const { path, blurhash } = await mediaManager.addFile(file); - - const url = Attachment.getUrl(path); - - let thumbnailUrl = ""; - - if (thumbnail) { - const { path } = await mediaManager.addFile(thumbnail); - - thumbnailUrl = Attachment.getUrl(path); - } - - const newAttachment = await Attachment.insert({ - url, - thumbnailUrl, - sha256: sha256.update(await file.arrayBuffer()).digest("hex"), - mimeType: file.type, - description: description ?? "", - size: file.size, - blurhash: blurhash ?? undefined, - width: metadata?.width ?? undefined, - height: metadata?.height ?? undefined, - }); - - // TODO: Add job to process videos and other media - - if (isImage) { - return context.json(newAttachment.toApi()); - } + app.openapi(route, async (context) => { + const { file, thumbnail, description } = context.req.valid("form"); + if (file.size > config.validation.max_media_size) { return context.json( { - ...newAttachment.toApi(), - url: null, + error: `File too large, max size is ${config.validation.max_media_size} bytes`, }, - 202, + 413, ); - }, - ), + } + + if ( + config.validation.enforce_mime_types && + !config.validation.allowed_mime_types.includes(file.type) + ) { + return context.json({ error: "Invalid file type" }, 415); + } + + const sha256 = new Bun.SHA256(); + + const isImage = file.type.startsWith("image/"); + + const metadata = isImage + ? await sharp(await file.arrayBuffer()).metadata() + : null; + + const mediaManager = new MediaManager(config); + + const { path, blurhash } = await mediaManager.addFile(file); + + const url = Attachment.getUrl(path); + + let thumbnailUrl = ""; + + if (thumbnail) { + const { path } = await mediaManager.addFile(thumbnail); + + thumbnailUrl = Attachment.getUrl(path); + } + + const newAttachment = await Attachment.insert({ + url, + thumbnailUrl, + sha256: sha256.update(await file.arrayBuffer()).digest("hex"), + mimeType: file.type, + description: description ?? "", + size: file.size, + blurhash: blurhash ?? undefined, + width: metadata?.width ?? undefined, + height: metadata?.height ?? undefined, + }); + + // TODO: Add job to process videos and other media + + return context.json(newAttachment.toApi(), 200); + }), ); diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts index 5e4329ca..54ba7e3b 100644 --- a/api/api/v2/search/index.ts +++ b/api/api/v2/search/index.ts @@ -2,11 +2,10 @@ import { apiRoute, applyConfig, auth, - handleZodError, parseUserAddress, userAddressValidator, } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { createRoute } from "@hono/zod-openapi"; import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; import { searchManager } from "~/classes/search/search-manager"; @@ -15,6 +14,7 @@ import { Instances, Notes, RolePermissions, Users } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { Note } from "~/packages/database-interface/note"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -38,7 +38,7 @@ export const meta = applyConfig({ export const schemas = { query: z.object({ - q: z.string().trim().optional(), + q: z.string().trim(), type: z.string().optional(), resolve: z.coerce.boolean().optional(), following: z.coerce.boolean().optional(), @@ -50,173 +50,204 @@ export const schemas = { }), }; +const route = createRoute({ + method: "get", + path: "/api/v2/search", + summary: "Instance database search", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Search results", + content: { + "application/json": { + schema: z.object({ + accounts: z.array(User.schema), + statuses: z.array(Note.schema), + hashtags: z.array(z.string()), + }), + }, + }, + }, + 401: { + description: + "Cannot use resolve or offset without being authenticated", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 501: { + description: "Search is not enabled on this server", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { user: self } = context.get("auth"); - const { q, type, resolve, following, account_id, limit, offset } = - context.req.valid("query"); + app.openapi(route, async (context) => { + const { user: self } = context.get("auth"); + const { q, type, resolve, following, account_id, limit, offset } = + context.req.valid("query"); - if (!self && (resolve || offset)) { - return context.json( - { - error: "Cannot use resolve or offset without being authenticated", - }, - 401, + if (!self && (resolve || offset)) { + return context.json( + { + error: "Cannot use resolve or offset without being authenticated", + }, + 401, + ); + } + + if (!config.sonic.enabled) { + return context.json( + { error: "Search is not enabled on this server" }, + 501, + ); + } + + let accountResults: string[] = []; + let statusResults: string[] = []; + + if (!type || type === "accounts") { + // Check if q is matching format username@domain.com or @username@domain.com + const accountMatches = q?.trim().match(userAddressValidator); + if (accountMatches) { + // Remove leading @ if it exists + if (accountMatches[0].startsWith("@")) { + accountMatches[0] = accountMatches[0].slice(1); + } + + const { username, domain } = parseUserAddress( + accountMatches[0], ); - } - if (!q) { - return context.json({ error: "Query is required" }, 400); - } + const accountId = ( + await db + .select({ + id: Users.id, + }) + .from(Users) + .leftJoin(Instances, eq(Users.instanceId, Instances.id)) + .where( + and( + eq(Users.username, username), + eq(Instances.baseUrl, domain), + ), + ) + )[0]?.id; - if (!config.sonic.enabled) { - return context.json( - { error: "Search is not enabled on this server" }, - 501, - ); - } + const account = accountId ? await User.fromId(accountId) : null; - let accountResults: string[] = []; - let statusResults: string[] = []; - - if (!type || type === "accounts") { - // Check if q is matching format username@domain.com or @username@domain.com - const accountMatches = q?.trim().match(userAddressValidator); - if (accountMatches) { - // Remove leading @ if it exists - if (accountMatches[0].startsWith("@")) { - accountMatches[0] = accountMatches[0].slice(1); - } - - const { username, domain } = parseUserAddress( - accountMatches[0], - ); - - const accountId = ( - await db - .select({ - id: Users.id, - }) - .from(Users) - .leftJoin( - Instances, - eq(Users.instanceId, Instances.id), - ) - .where( - and( - eq(Users.username, username), - eq(Instances.baseUrl, domain), - ), - ) - )[0]?.id; - - const account = accountId - ? await User.fromId(accountId) - : null; - - if (account) { - return context.json({ + if (account) { + return context.json( + { accounts: [account.toApi()], statuses: [], hashtags: [], - }); - } + }, + 200, + ); + } - if (resolve) { - const manager = await ( - self ?? User - ).getFederationRequester(); + if (resolve) { + const manager = await ( + self ?? User + ).getFederationRequester(); - const uri = await User.webFinger( - manager, - username, - domain, - ); + const uri = await User.webFinger(manager, username, domain); - const newUser = await User.resolve(uri); + const newUser = await User.resolve(uri); - if (newUser) { - return context.json({ + if (newUser) { + return context.json( + { accounts: [newUser.toApi()], statuses: [], hashtags: [], - }); - } + }, + 200, + ); } } - - accountResults = await searchManager.searchAccounts( - q, - Number(limit) || 10, - Number(offset) || 0, - ); } - if (!type || type === "statuses") { - statusResults = await searchManager.searchStatuses( - q, - Number(limit) || 10, - Number(offset) || 0, - ); - } + accountResults = await searchManager.searchAccounts( + q, + Number(limit) || 10, + Number(offset) || 0, + ); + } - const accounts = - accountResults.length > 0 - ? await User.manyFromSql( - and( - inArray( - Users.id, - accountResults.map((hit) => hit), - ), - self && following - ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${ - self?.id - } AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${ - Users.id - })` - : undefined, + if (!type || type === "statuses") { + statusResults = await searchManager.searchStatuses( + q, + Number(limit) || 10, + Number(offset) || 0, + ); + } + + const accounts = + accountResults.length > 0 + ? await User.manyFromSql( + and( + inArray( + Users.id, + accountResults.map((hit) => hit), ), - ) - : []; + self && following + ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${ + self?.id + } AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${ + Users.id + })` + : undefined, + ), + ) + : []; - const statuses = - statusResults.length > 0 - ? await Note.manyFromSql( - and( - inArray( - Notes.id, - statusResults.map((hit) => hit), - ), - account_id - ? eq(Notes.authorId, account_id) - : undefined, - self && following - ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${ - self?.id - } AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${ - Notes.authorId - })` - : undefined, + const statuses = + statusResults.length > 0 + ? await Note.manyFromSql( + and( + inArray( + Notes.id, + statusResults.map((hit) => hit), ), - undefined, - undefined, - undefined, - self?.id, - ) - : []; + account_id + ? eq(Notes.authorId, account_id) + : undefined, + self && following + ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${ + self?.id + } AND "Relationships".following = ${!!following} AND "Relationships"."ownerId" = ${ + Notes.authorId + })` + : undefined, + ), + undefined, + undefined, + undefined, + self?.id, + ) + : []; - return context.json({ + return context.json( + { accounts: accounts.map((account) => account.toApi()), statuses: await Promise.all( statuses.map((status) => status.toApi(self)), ), hashtags: [], - }); - }, - ), + }, + 200, + ); + }), ); diff --git a/api/media/:hash/:name/index.ts b/api/media/:hash/:name/index.ts index 8aef6dd7..ae60df53 100644 --- a/api/media/:hash/:name/index.ts +++ b/api/media/:hash/:name/index.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig, handleZodError } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { z } from "zod"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -24,40 +25,63 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - zValidator("header", schemas.header, handleZodError), - 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}`, - }); +const route = createRoute({ + method: "get", + path: "/media/{hash}/{name}", + summary: "Get media file by hash and name", + request: { + params: schemas.param, + headers: schemas.header, + }, + responses: { + 200: { + description: "Media", + content: { + "*": { + schema: z.any(), + }, + }, }, - ), + 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; + }), ); diff --git a/api/media/proxy/:id.ts b/api/media/proxy/:id.ts index 9019b31a..7d77b32f 100644 --- a/api/media/proxy/:id.ts +++ b/api/media/proxy/:id.ts @@ -1,8 +1,9 @@ -import { apiRoute, applyConfig, handleZodError } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import type { StatusCode } from "hono/utils/http-status"; import { z } from "zod"; import { config } from "~/packages/config-manager"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -24,54 +25,76 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - 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", +const route = createRoute({ + method: "get", + path: "/media/proxy/{id}", + summary: "Proxy media through the server", + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Media", + content: { + "*": { + schema: z.any(), }, - // @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; + }), ); diff --git a/api/oauth/sso/:issuer/callback/index.ts b/api/oauth/sso/:issuer/callback/index.ts index d92917ad..3ba4e3ad 100644 --- a/api/oauth/sso/:issuer/callback/index.ts +++ b/api/oauth/sso/:issuer/callback/index.ts @@ -1,7 +1,7 @@ -import { apiRoute, applyConfig, handleZodError } from "@/api"; +import { apiRoute, applyConfig } from "@/api"; import { randomString } from "@/math"; 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 type { Context } from "hono"; 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 = ( context: Context, 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) => - app.on( - meta.allowedMethods, - meta.route, - 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); + app.openapi(route, 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 - // Looking at you, Traefik - if ( - new URL(config.http.base_url).protocol === "https:" && - currentUrl.protocol === "http:" - ) { - currentUrl.protocol = "https:"; - redirectUrl.protocol = "https:"; - } + // Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https + // Looking at you, Traefik + if ( + new URL(config.http.base_url).protocol === "https:" && + currentUrl.protocol === "http:" + ) { + currentUrl.protocol = "https:"; + redirectUrl.protocol = "https:"; + } - // Remove state query parameter from URL - currentUrl.searchParams.delete("state"); - redirectUrl.searchParams.delete("state"); - // Remove issuer query parameter from URL (can cause redirect URI mismatches) - redirectUrl.searchParams.delete("iss"); - redirectUrl.searchParams.delete("code"); - const { issuer: issuerParam } = context.req.valid("param"); - const { flow: flowId, user_id, link } = context.req.valid("query"); + // Remove state query parameter from URL + currentUrl.searchParams.delete("state"); + redirectUrl.searchParams.delete("state"); + // Remove issuer query parameter from URL (can cause redirect URI mismatches) + redirectUrl.searchParams.delete("iss"); + redirectUrl.searchParams.delete("code"); + const { issuer: issuerParam } = context.req.valid("param"); + 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( - flowId, - currentUrl, - redirectUrl, - (error, message, app) => - returnError( - context, - manager.processOAuth2Error(app), - error, - message, + const userInfo = await manager.automaticOidcFlow( + flowId, + currentUrl, + redirectUrl, + (error, message, app) => + returnError( + context, + manager.processOAuth2Error(app), + error, + 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) { - return userInfo; - } + if (!userId) { + // 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 } = - 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 (!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), + 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( - async (value) => - !(await User.fromSql( - and( - eq(Users.username, value), - isNull(Users.instanceId), - ), - )), - ); - - try { - 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", + ) + .refine((value) => + config.filters.username.some((filter) => + value.match(filter), + ), + ) + .refine( + async (value) => + !(await User.fromSql( + and( + eq(Users.username, value), + isNull(Users.instanceId), + ), + )), ); + + 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( context, { @@ -224,80 +211,96 @@ export default apiRoute((app) => "No user found with that account", ); } + } - if (!user.hasPermission(RolePermissions.OAuth)) { - 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`, - ); - } + const user = await User.fromId(userId); - 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"], + if (!user) { + 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", ); + } - // 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(), + if (!user.hasPermission(RolePermissions.OAuth)) { + 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) { + 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(), + ); + }), ); diff --git a/api/oauth/sso/index.ts b/api/oauth/sso/index.ts index b92b4868..8957cc06 100644 --- a/api/oauth/sso/index.ts +++ b/api/oauth/sso/index.ts @@ -1,6 +1,6 @@ -import { apiRoute, applyConfig, handleZodError } from "@/api"; +import { apiRoute, applyConfig } from "@/api"; import { oauthRedirectUri } from "@/constants"; -import { zValidator } from "@hono/zod-validator"; +import { createRoute } from "@hono/zod-openapi"; import type { Context } from "hono"; import { 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 = ( context: Context, query: object, @@ -59,87 +74,80 @@ const returnError = ( }; export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - 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(); + app.openapi(route, 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") { - return returnError( - context, - body, - "invalid_request", - "client_id is required", - ); - } - - const issuer = config.oidc.providers.find( - (provider) => provider.id === issuerId, + if (!client_id || client_id === "undefined") { + return returnError( + context, + body, + "invalid_request", + "client_id is required", ); + } - if (!issuer) { - return returnError( - context, - body, - "invalid_request", - "issuer is invalid", - ); - } + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerId, + ); - 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()}`, + if (!issuer) { + return returnError( + context, + body, + "invalid_request", + "issuer is invalid", ); - }, - ), + } + + 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()}`, + ); + }), ); diff --git a/api/oauth/token/index.ts b/api/oauth/token/index.ts index f0e37a3e..58b70a79 100644 --- a/api/oauth/token/index.ts +++ b/api/oauth/token/index.ts @@ -1,5 +1,5 @@ -import { apiRoute, applyConfig, handleZodError, jsonOrForm } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, jsonOrForm } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { z } from "zod"; 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) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - zValidator("json", schemas.json, handleZodError), - async (context) => { - const { grant_type, code, redirect_uri, client_id, client_secret } = - context.req.valid("json"); + app.openapi(route, async (context) => { + const { grant_type, code, redirect_uri, client_id, client_secret } = + context.req.valid("json"); - switch (grant_type) { - case "authorization_code": { - if (!code) { - return context.json( - { - error: "invalid_request", - error_description: "Code is required", - }, - 401, - ); - } + switch (grant_type) { + case "authorization_code": { + if (!code) { + return context.json( + { + error: "invalid_request", + error_description: "Code is required", + }, + 401, + ); + } - if (!redirect_uri) { - return context.json( - { - error: "invalid_request", - error_description: "Redirect URI is required", - }, - 401, - ); - } + if (!redirect_uri) { + return context.json( + { + error: "invalid_request", + error_description: "Redirect URI is required", + }, + 401, + ); + } - if (!client_id) { - return context.json( - { - error: "invalid_request", - error_description: "Client ID is required", - }, - 401, - ); - } + if (!client_id) { + return context.json( + { + error: "invalid_request", + error_description: "Client ID is required", + }, + 401, + ); + } - // Verify the client_secret - const client = await db.query.Applications.findFirst({ - where: (application, { eq }) => - eq(application.clientId, client_id), - }); + // Verify the client_secret + const client = await db.query.Applications.findFirst({ + where: (application, { eq }) => + eq(application.clientId, client_id), + }); - if (!client || client.secret !== client_secret) { - return context.json( - { - error: "invalid_client", - error_description: "Invalid client credentials", - }, - 401, - ); - } + if (!client || client.secret !== client_secret) { + return context.json( + { + error: "invalid_client", + error_description: "Invalid client credentials", + }, + 401, + ); + } - const token = await db.query.Tokens.findFirst({ - where: (token, { eq, and }) => - and( - eq(token.code, code), - eq(token.redirectUri, decodeURI(redirect_uri)), - eq(token.clientId, client_id), - ), - }); + const token = await db.query.Tokens.findFirst({ + where: (token, { eq, and }) => + and( + eq(token.code, code), + eq(token.redirectUri, decodeURI(redirect_uri)), + eq(token.clientId, client_id), + ), + }); - if (!token) { - return context.json( - { - error: "invalid_grant", - error_description: "Code not found", - }, - 401, - ); - } + if (!token) { + return context.json( + { + error: "invalid_grant", + error_description: "Code not found", + }, + 401, + ); + } - // Invalidate the code - await db - .update(Tokens) - .set({ code: null }) - .where(eq(Tokens.id, token.id)); + // Invalidate the code + await db + .update(Tokens) + .set({ code: null }) + .where(eq(Tokens.id, token.id)); - return context.json({ + return context.json( + { access_token: token.accessToken, token_type: "Bearer", expires_in: token.expiresAt @@ -149,17 +196,18 @@ export default apiRoute((app) => created_at: Math.floor( new Date(token.createdAt).getTime() / 1000, ), - }); - } + }, + 200, + ); } + } - return context.json( - { - error: "unsupported_grant_type", - error_description: "Unsupported grant type", - }, - 401, - ); - }, - ), + return context.json( + { + error: "unsupported_grant_type", + error_description: "Unsupported grant type", + }, + 401, + ); + }), ); diff --git a/api/objects/:id/index.ts b/api/objects/:id/index.ts index d8dd64c0..c04bd488 100644 --- a/api/objects/:id/index.ts +++ b/api/objects/:id/index.ts @@ -1,5 +1,9 @@ -import { apiRoute, applyConfig, handleZodError } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig } from "@/api"; +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 { z } from "zod"; import { type LikeType, likeToVersia } from "~/classes/functions/like"; @@ -8,7 +12,7 @@ import { Notes } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { Note } from "~/packages/database-interface/note"; import { User } from "~/packages/database-interface/user"; -import type { KnownEntity } from "~/types/api"; +import { ErrorSchema, type KnownEntity } from "~/types/api"; export const meta = applyConfig({ 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) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - async (context) => { - const { id } = context.req.valid("param"); + app.openapi(route, async (context) => { + const { id } = context.req.valid("param"); - let foundObject: Note | LikeType | null = null; - let foundAuthor: User | null = null; - let apiObject: KnownEntity | null = null; + let foundObject: Note | LikeType | null = null; + let foundAuthor: User | null = null; + let apiObject: KnownEntity | null = null; - foundObject = await Note.fromSql( - and( - eq(Notes.id, id), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - apiObject = foundObject ? foundObject.toVersia() : null; - foundAuthor = foundObject ? foundObject.author : null; + foundObject = await Note.fromSql( + and( + eq(Notes.id, id), + inArray(Notes.visibility, ["public", "unlisted"]), + ), + ); + apiObject = foundObject ? foundObject.toVersia() : null; + foundAuthor = foundObject ? foundObject.author : null; - if (foundObject) { - 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)) { + if (foundObject) { + 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 (!foundAuthor) { - return context.json({ error: "Author not found" }, 404); - } + if (!(foundObject && apiObject)) { + return context.json({ error: "Object not found" }, 404); + } - if (foundAuthor?.isRemote()) { - return context.json( - { 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:"; - } + if (!foundAuthor) { + return context.json({ error: "Author not found" }, 404); + } - const { headers } = await foundAuthor.sign( - apiObject, - reqUrl, - "GET", + if (foundAuthor?.isRemote()) { + return context.json( + { 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:"; + } - return context.json(apiObject, 200, headers.toJSON()); - }, - ), + const { headers } = await foundAuthor.sign(apiObject, reqUrl, "GET"); + + return context.json(apiObject, 200, headers.toJSON()); + }), ); diff --git a/api/users/:uuid/inbox/index.ts b/api/users/:uuid/inbox/index.ts index 734988dd..17a8867a 100644 --- a/api/users/:uuid/inbox/index.ts +++ b/api/users/:uuid/inbox/index.ts @@ -1,6 +1,6 @@ -import { apiRoute, applyConfig, debugRequest, handleZodError } from "@/api"; +import { apiRoute, applyConfig, debugRequest } from "@/api"; import { sentry } from "@/sentry"; -import { zValidator } from "@hono/zod-validator"; +import { createRoute } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import { EntityValidator, @@ -8,7 +8,6 @@ import { SignatureValidator, } from "@versia/federation"; import type { Entity } from "@versia/federation/types"; -import type { SocketAddress } from "bun"; import { eq } from "drizzle-orm"; import { matches } from "ip-matching"; import { z } from "zod"; @@ -20,6 +19,7 @@ import { config } from "~/packages/config-manager"; import { Note } from "~/packages/database-interface/note"; import { Relationship } from "~/packages/database-interface/relationship"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -46,388 +46,413 @@ export const schemas = { body: z.any(), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - zValidator("header", schemas.header, handleZodError), - zValidator("json", schemas.body, handleZodError), - 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"]); - - const body: Entity = await context.req.valid("json"); - - if (config.debug.federation) { - // Debug request - await debugRequest( - new Request(context.req.url, { - method: context.req.method, - headers: context.req.raw.headers, - body: await context.req.text(), +const route = createRoute({ + method: "post", + path: "/users/{uuid}/inbox", + summary: "Receive federation inbox", + request: { + params: schemas.param, + headers: schemas.header, + body: { + content: { + "application/json": { + schema: schemas.body, + }, + }, + }, + }, + responses: { + 200: { + description: "Request processed", + }, + 201: { + description: "Request accepted", + }, + 400: { + description: "Bad request", + content: { + "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) { - return context.json({ error: "User not found" }, 404); - } + const body: Entity = await context.req.valid("json"); - if (user.isRemote()) { - return context.json( - { error: "Cannot view users from remote instances" }, - 403, - ); - } + if (config.debug.federation) { + // Debug request + await debugRequest( + 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 requestIp = context.env?.ip as - | SocketAddress - | undefined - | null; + const user = await User.fromId(uuid); - let checkSignature = true; + if (!user) { + return context.json({ error: "User not found" }, 404); + } - if (config.federation.bridge.enabled) { - const token = authorization?.split("Bearer ")[1]; - if (token) { - // Request is bridge request - 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 (user.isRemote()) { + return context.json( + { error: "Cannot view users from remote instances" }, + 403, + ); + } - if (requestIp?.address) { - if (config.federation.bridge.allowed_ips.length > 0) { - checkSignature = false; - } + const requestIp = context.env?.ip; - for (const ip of config.federation.bridge.allowed_ips) { - if (matches(ip, requestIp?.address)) { - checkSignature = false; - break; - } - } - } else { - return context.json( - { - error: "Request IP address is not available", - }, - 500, - ); - } - } - } + let checkSignature = true; - const sender = await User.resolve(signedBy); - - if (sender?.isLocal()) { - return context.json( - { error: "Cannot send federation requests to local users" }, - 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) { + if (config.federation.bridge.enabled) { + const token = authorization?.split("Bearer ")[1]; + if (token) { + // Request is bridge request + if (token !== config.federation.bridge.token) { 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) { - // Log public key - logger.debug`Sender public key: ${sender.data.publicKey}`; - } + if (requestIp?.address) { + if (config.federation.bridge.allowed_ips.length > 0) { + checkSignature = false; + } - const validator = await SignatureValidator.fromStringKey( - sender.data.publicKey, - ); - - const isValid = await validator - .validate( - new Request(context.req.url, { - 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: "Invalid signature" }, 401); + for (const ip of config.federation.bridge.allowed_ips) { + if (matches(ip, requestIp?.address)) { + checkSignature = false; + break; + } + } + } else { + return context.json( + { + error: "Request IP address is not available", + }, + 500, + ); } } + } - const validator = new EntityValidator(); - const handler = new RequestParserHandler(body, validator); + const sender = await User.resolve(signedBy); - try { - return await handler.parseBody({ - note: async (note) => { - const account = await User.resolve(note.author); + if (sender?.isLocal()) { + return context.json( + { error: "Cannot send federation requests to local users" }, + 400, + ); + } - if (!account) { - return context.json( - { error: "Author not found" }, - 404, - ); - } + const hostname = sender?.data.instance?.baseUrl ?? ""; - const newStatus = await Note.fromVersia( - note, - account, - ).catch((e) => { - logger.error`${e}`; - sentry?.captureException(e); - return null; - }); + // 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); + } - if (!newStatus) { - return context.json( - { error: "Failed to add status" }, - 500, - ); - } + // Verify request signature + if (checkSignature) { + if (!sender) { + return context.json({ error: "Could not resolve sender" }, 400); + } - return context.text("Note created", 201); - }, - follow: async (follow) => { - const account = await User.resolve(follow.author); + if (config.debug.federation) { + // Log public key + logger.debug`Sender public key: ${sender.data.publicKey}`; + } - if (!account) { - return context.json( - { error: "Author not found" }, - 400, - ); - } + const validator = await SignatureValidator.fromStringKey( + sender.data.publicKey, + ); - const foundRelationship = - await Relationship.fromOwnerAndSubject( - account, - user, + const isValid = await validator + .validate( + new Request(context.req.url, { + 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({ + 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) { - 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 (note) { - await note.delete(); - return context.text("Note deleted", 200); - } - - break; + if (note) { + await note.delete(); + return context.text("Note deleted", 200); } - case "User": { - const otherUser = await User.resolve(toDelete); - if (otherUser) { - if (otherUser.id === user.id) { - // Delete own account - await user.delete(); - return context.text( - "Account deleted", - 200, - ); - } - return context.json( - { - error: "Cannot delete other users than self", - }, - 400, - ); + break; + } + case "User": { + const otherUser = await User.resolve(toDelete); + + if (otherUser) { + if (otherUser.id === user.id) { + // Delete own account + await user.delete(); + return context.text("Account deleted", 200); } - - break; - } - default: { return context.json( { - error: `Deletetion of object ${toDelete} not implemented`, + error: "Cannot delete other users than self", }, 400, ); } + + break; } - - 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) { + default: { 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( - { - error: "Failed to process request", - error_description: (e as ValidationError).message, - }, - 400, + { error: "Object not found or not owned by user" }, + 404, ); - } - logger.error`${e}`; - sentry?.captureException(e); + }, + user: async (user) => { + // 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( { 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, + ); + } + }), ); diff --git a/api/users/:uuid/index.ts b/api/users/:uuid/index.ts index 82639980..951f5417 100644 --- a/api/users/:uuid/index.ts +++ b/api/users/:uuid/index.ts @@ -1,7 +1,9 @@ -import { apiRoute, applyConfig, handleZodError } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; +import { User as UserSchema } from "@versia/federation/schemas"; import { z } from "zod"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -21,44 +23,71 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - 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") && - 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()); +const route = createRoute({ + method: "get", + path: "/users/{uuid}", + summary: "Get user data", + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "User data", + content: { + "application/json": { + schema: UserSchema, + }, + }, }, - ), + 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()); + }), ); diff --git a/api/users/:uuid/outbox/index.ts b/api/users/:uuid/outbox/index.ts index 1dbba9bb..57874c69 100644 --- a/api/users/:uuid/outbox/index.ts +++ b/api/users/:uuid/outbox/index.ts @@ -1,6 +1,9 @@ -import { apiRoute, applyConfig, handleZodError } from "@/api"; -import { zValidator } from "@hono/zod-validator"; -import type { Collection } from "@versia/federation/types"; +import { apiRoute, applyConfig } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; +import { + Collection as CollectionSchema, + Note as NoteSchema, +} from "@versia/federation/schemas"; import { and, count, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "~/drizzle/db"; @@ -8,6 +11,7 @@ import { Notes } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { Note } from "~/packages/database-interface/note"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -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; export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - zValidator("query", schemas.query, handleZodError), - async (context) => { - const { uuid } = context.req.valid("param"); + app.openapi(route, async (context) => { + const { uuid } = context.req.valid("param"); - const author = await User.fromId(uuid); + const author = await User.fromId(uuid); - if (!author) { - return context.json({ error: "User not found" }, 404); - } + if (!author) { + return context.json({ error: "User not found" }, 404); + } - if (author.isRemote()) { - return context.json( - { error: "Cannot view users from remote instances" }, - 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), + if (author.isRemote()) { + return context.json( + { error: "Cannot view users from remote instances" }, + 403, ); + } - const totalNotes = ( - await db - .select({ - count: count(), - }) - .from(Notes) - .where( - and( - eq(Notes.authorId, uuid), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ) - )[0].count; + const pageNumber = Number(context.req.valid("query").page) || 1; - 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()), - } satisfies Collection; + 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 { 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()); + }), ); diff --git a/api/well-known/host-meta/index.ts b/api/well-known/host-meta/index.ts index d01f616a..08987509 100644 --- a/api/well-known/host-meta/index.ts +++ b/api/well-known/host-meta/index.ts @@ -1,4 +1,5 @@ import { apiRoute, applyConfig } from "@/api"; +import { createRoute, z } from "@hono/zod-openapi"; import { config } from "~/packages/config-manager"; export const meta = applyConfig({ @@ -13,14 +14,34 @@ export const meta = applyConfig({ 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) => - app.on(meta.allowedMethods, meta.route, (context) => { + app.openapi(route, (context) => { context.header("Content-Type", "application/xrd+xml"); + context.status(200); + return context.body( ``, - ); + 200, + // biome-ignore lint/suspicious/noExplicitAny: Hono doesn't type this response so this has a TS error, it's joever + ) as any; }), ); diff --git a/api/well-known/jwks/index.ts b/api/well-known/jwks/index.ts index fdb55906..042afb3b 100644 --- a/api/well-known/jwks/index.ts +++ b/api/well-known/jwks/index.ts @@ -1,4 +1,5 @@ import { apiRoute, applyConfig } from "@/api"; +import { createRoute, z } from "@hono/zod-openapi"; import { exportJWK } from "jose"; import { config } from "~/packages/config-manager"; @@ -14,8 +15,36 @@ export const meta = applyConfig({ 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) => - app.on(meta.allowedMethods, meta.route, async (context) => { + app.openapi(route, async (context) => { const publicKey = await crypto.subtle.importKey( "spki", Buffer.from(config.oidc.keys?.public ?? "", "base64"), @@ -29,15 +58,18 @@ export default apiRoute((app) => // Remove the private key jwk.d = undefined; - return context.json({ - keys: [ - { - ...jwk, - use: "sig", - alg: "EdDSA", - kid: "1", - }, - ], - }); + return context.json( + { + keys: [ + { + ...jwk, + use: "sig", + alg: "EdDSA", + kid: "1", + }, + ], + }, + 200, + ); }), ); diff --git a/api/well-known/nodeinfo/2.0/index.ts b/api/well-known/nodeinfo/2.0/index.ts index a753f259..ce2bdf92 100644 --- a/api/well-known/nodeinfo/2.0/index.ts +++ b/api/well-known/nodeinfo/2.0/index.ts @@ -1,4 +1,5 @@ import { apiRoute, applyConfig } from "@/api"; +import { createRoute, z } from "@hono/zod-openapi"; import manifest from "~/package.json"; export const meta = applyConfig({ @@ -13,8 +14,45 @@ export const meta = applyConfig({ 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) => - app.on(meta.allowedMethods, meta.route, (context) => { + app.openapi(route, (context) => { return context.json({ version: "2.0", software: { name: "versia-server", version: manifest.version }, diff --git a/api/well-known/nodeinfo/index.ts b/api/well-known/nodeinfo/index.ts index e3a5b298..1549f9a3 100644 --- a/api/well-known/nodeinfo/index.ts +++ b/api/well-known/nodeinfo/index.ts @@ -1,4 +1,5 @@ import { apiRoute, applyConfig } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { config } from "~/packages/config-manager"; export const meta = applyConfig({ @@ -13,8 +14,19 @@ export const meta = applyConfig({ 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) => - app.on(meta.allowedMethods, meta.route, (context) => { + app.openapi(route, (context) => { return context.redirect( new URL( "/.well-known/nodeinfo/2.0", diff --git a/api/well-known/openid-configuration/index.ts b/api/well-known/openid-configuration/index.ts index deaf55ea..f2c29030 100644 --- a/api/well-known/openid-configuration/index.ts +++ b/api/well-known/openid-configuration/index.ts @@ -1,4 +1,5 @@ import { apiRoute, applyConfig } from "@/api"; +import { createRoute, z } from "@hono/zod-openapi"; import { config } from "~/packages/config-manager"; export const meta = applyConfig({ @@ -13,21 +14,56 @@ export const meta = applyConfig({ 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) => - app.on(meta.allowedMethods, meta.route, (context) => { + app.openapi(route, (context) => { const baseUrl = new URL(config.http.base_url); - return context.json({ - issuer: baseUrl.origin.toString(), - authorization_endpoint: `${baseUrl.origin}/oauth/authorize`, - token_endpoint: `${baseUrl.origin}/oauth/token`, - userinfo_endpoint: `${baseUrl.origin}/api/v1/accounts/verify_credentials`, - jwks_uri: `${baseUrl.origin}/.well-known/jwks`, - response_types_supported: ["code"], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["EdDSA"], - scopes_supported: ["openid", "profile", "email"], - token_endpoint_auth_methods_supported: ["client_secret_basic"], - claims_supported: ["sub"], - }); + return context.json( + { + issuer: baseUrl.origin.toString(), + authorization_endpoint: `${baseUrl.origin}/oauth/authorize`, + token_endpoint: `${baseUrl.origin}/oauth/token`, + userinfo_endpoint: `${baseUrl.origin}/api/v1/accounts/verify_credentials`, + jwks_uri: `${baseUrl.origin}/.well-known/jwks`, + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["EdDSA"], + scopes_supported: ["openid", "profile", "email"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + claims_supported: ["sub"], + }, + 200, + ); }), ); diff --git a/api/well-known/versia.ts b/api/well-known/versia.ts index 3ef0fc76..cc6e3e57 100644 --- a/api/well-known/versia.ts +++ b/api/well-known/versia.ts @@ -1,8 +1,12 @@ import { apiRoute, applyConfig } from "@/api"; 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 { config } from "~/packages/config-manager"; +import { User } from "~/packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -16,28 +20,52 @@ export const meta = applyConfig({ 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) => - app.on(meta.allowedMethods, meta.route, (context) => { - return context.json({ - type: "InstanceMetadata", - compatibility: { - extensions: ["pub.versia:custom_emojis"], - versions: ["0.4.0"], + app.openapi(route, async (context) => { + // Get date of first user creation + const firstUser = await User.fromSql(undefined, asc(Users.createdAt)); + + return context.json( + { + 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, - 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); + 200, + ); }), ); diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts index 28ee31ca..8d3745b1 100644 --- a/api/well-known/webfinger/index.ts +++ b/api/well-known/webfinger/index.ts @@ -1,11 +1,5 @@ -import { - apiRoute, - applyConfig, - handleZodError, - idValidator, - webfingerMention, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, idValidator, webfingerMention } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import type { ResponseError } from "@versia/federation"; import { and, eq, isNull } from "drizzle-orm"; @@ -14,6 +8,7 @@ import { z } from "zod"; import { Users } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -29,86 +24,118 @@ export const meta = applyConfig({ export const schemas = { 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) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - async (context) => { - const { resource } = context.req.valid("query"); + app.openapi(route, async (context) => { + const { resource } = context.req.valid("query"); - // Check if resource is in the correct format (acct:uuid/username@domain) - 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 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 - if (requestedUser.split("@")[1] !== host) { - return context.json({ error: "User is a remote user" }, 404); - } + const isUuid = requestedUser.split("@")[0].match(idValidator); - const isUuid = requestedUser.split("@")[0].match(idValidator); - - const user = await User.fromSql( - and( - eq( - isUuid ? Users.id : Users.username, - requestedUser.split("@")[0], - ), - isNull(Users.instanceId), + const user = await User.fromSql( + and( + eq( + isUuid ? Users.id : Users.username, + requestedUser.split("@")[0], ), - ); + isNull(Users.instanceId), + ), + ); - if (!user) { - return context.json({ error: "User not found" }, 404); + if (!user) { + 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 = ""; - - 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}`; - } - } - - return context.json({ - subject: `acct:${ - isUuid ? user.id : user.data.username - }@${host}`, + return context.json( + { + subject: `acct:${isUuid ? user.id : user.data.username}@${host}`, links: [ // Keep the ActivityPub link first, because Misskey only searches // for the first link with rel="self" and doesn't check the type. - activityPubUrl && { - rel: "self", - type: "application/activity+json", - href: activityPubUrl, - }, + activityPubUrl + ? { + rel: "self", + type: "application/activity+json", + href: activityPubUrl, + } + : undefined, { rel: "self", type: "application/json", @@ -119,11 +146,18 @@ export default apiRoute((app) => }, { rel: "avatar", - type: lookup(user.getAvatarUrl(config)), + type: + lookup(user.getAvatarUrl(config)) ?? + "application/octet-stream", href: user.getAvatarUrl(config), }, - ].filter(Boolean), - }); - }, - ), + ].filter(Boolean) as { + rel: string; + type: string; + href: string; + }[], + }, + 200, + ); + }), ); diff --git a/bun.lockb b/bun.lockb index 542fb7d0..eda57002 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle/migrations/0033_panoramic_sister_grimm.sql b/drizzle/migrations/0033_panoramic_sister_grimm.sql new file mode 100644 index 00000000..10a9cb9d --- /dev/null +++ b/drizzle/migrations/0033_panoramic_sister_grimm.sql @@ -0,0 +1 @@ +ALTER TABLE "Filters" ALTER COLUMN "context" SET NOT NULL; \ No newline at end of file diff --git a/drizzle/migrations/meta/0033_snapshot.json b/drizzle/migrations/meta/0033_snapshot.json new file mode 100644 index 00000000..f764037a --- /dev/null +++ b/drizzle/migrations/meta/0033_snapshot.json @@ -0,0 +1,2126 @@ +{ + "id": "b96888a6-97a0-4c7a-9130-a33a418a50a3", + "prevId": "0066d399-ea8e-481c-86d8-7f44b81ed808", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Applications": { + "name": "Applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vapid_key": { + "name": "vapid_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "Applications_client_id_index": { + "name": "Applications_client_id_index", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Attachments": { + "name": "Attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blurhash": { + "name": "blurhash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Attachments_noteId_Notes_id_fk": { + "name": "Attachments_noteId_Notes_id_fk", + "tableFrom": "Attachments", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Challenges": { + "name": "Challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "challenge": { + "name": "challenge", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "NOW() + INTERVAL '5 minutes'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.EmojiToNote": { + "name": "EmojiToNote", + "schema": "", + "columns": { + "emojiId": { + "name": "emojiId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "EmojiToNote_emojiId_noteId_index": { + "name": "EmojiToNote_emojiId_noteId_index", + "columns": [ + { + "expression": "emojiId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "EmojiToNote_noteId_index": { + "name": "EmojiToNote_noteId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "EmojiToNote_emojiId_Emojis_id_fk": { + "name": "EmojiToNote_emojiId_Emojis_id_fk", + "tableFrom": "EmojiToNote", + "tableTo": "Emojis", + "columnsFrom": ["emojiId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "EmojiToNote_noteId_Notes_id_fk": { + "name": "EmojiToNote_noteId_Notes_id_fk", + "tableFrom": "EmojiToNote", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.EmojiToUser": { + "name": "EmojiToUser", + "schema": "", + "columns": { + "emojiId": { + "name": "emojiId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "EmojiToUser_emojiId_userId_index": { + "name": "EmojiToUser_emojiId_userId_index", + "columns": [ + { + "expression": "emojiId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "EmojiToUser_userId_index": { + "name": "EmojiToUser_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "EmojiToUser_emojiId_Emojis_id_fk": { + "name": "EmojiToUser_emojiId_Emojis_id_fk", + "tableFrom": "EmojiToUser", + "tableTo": "Emojis", + "columnsFrom": ["emojiId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "EmojiToUser_userId_Users_id_fk": { + "name": "EmojiToUser_userId_Users_id_fk", + "tableFrom": "EmojiToUser", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Emojis": { + "name": "Emojis", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "shortcode": { + "name": "shortcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visible_in_picker": { + "name": "visible_in_picker", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instanceId": { + "name": "instanceId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ownerId": { + "name": "ownerId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Emojis_instanceId_Instances_id_fk": { + "name": "Emojis_instanceId_Instances_id_fk", + "tableFrom": "Emojis", + "tableTo": "Instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Emojis_ownerId_Users_id_fk": { + "name": "Emojis_ownerId_Users_id_fk", + "tableFrom": "Emojis", + "tableTo": "Users", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.FilterKeywords": { + "name": "FilterKeywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "filterId": { + "name": "filterId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whole_word": { + "name": "whole_word", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "FilterKeywords_filterId_Filters_id_fk": { + "name": "FilterKeywords_filterId_Filters_id_fk", + "tableFrom": "FilterKeywords", + "tableTo": "Filters", + "columnsFrom": ["filterId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Filters": { + "name": "Filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter_action": { + "name": "filter_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Filters_userId_Users_id_fk": { + "name": "Filters_userId_Users_id_fk", + "tableFrom": "Filters", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Flags": { + "name": "Flags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "flag_type": { + "name": "flag_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Flags_noteId_Notes_id_fk": { + "name": "Flags_noteId_Notes_id_fk", + "tableFrom": "Flags", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Flags_userId_Users_id_fk": { + "name": "Flags_userId_Users_id_fk", + "tableFrom": "Flags", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Instances": { + "name": "Instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disable_automoderation": { + "name": "disable_automoderation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'versia'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Likes": { + "name": "Likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "likerId": { + "name": "likerId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "likedId": { + "name": "likedId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Likes_likerId_Users_id_fk": { + "name": "Likes_likerId_Users_id_fk", + "tableFrom": "Likes", + "tableTo": "Users", + "columnsFrom": ["likerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Likes_likedId_Notes_id_fk": { + "name": "Likes_likedId_Notes_id_fk", + "tableFrom": "Likes", + "tableTo": "Notes", + "columnsFrom": ["likedId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Markers": { + "name": "Markers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notificationId": { + "name": "notificationId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timeline": { + "name": "timeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Markers_noteId_Notes_id_fk": { + "name": "Markers_noteId_Notes_id_fk", + "tableFrom": "Markers", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Markers_notificationId_Notifications_id_fk": { + "name": "Markers_notificationId_Notifications_id_fk", + "tableFrom": "Markers", + "tableTo": "Notifications", + "columnsFrom": ["notificationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Markers_userId_Users_id_fk": { + "name": "Markers_userId_Users_id_fk", + "tableFrom": "Markers", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ModNotes": { + "name": "ModNotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "modId": { + "name": "modId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ModNotes_noteId_Notes_id_fk": { + "name": "ModNotes_noteId_Notes_id_fk", + "tableFrom": "ModNotes", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModNotes_userId_Users_id_fk": { + "name": "ModNotes_userId_Users_id_fk", + "tableFrom": "ModNotes", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModNotes_modId_Users_id_fk": { + "name": "ModNotes_modId_Users_id_fk", + "tableFrom": "ModNotes", + "tableTo": "Users", + "columnsFrom": ["modId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.ModTags": { + "name": "ModTags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "modId": { + "name": "modId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ModTags_noteId_Notes_id_fk": { + "name": "ModTags_noteId_Notes_id_fk", + "tableFrom": "ModTags", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModTags_userId_Users_id_fk": { + "name": "ModTags_userId_Users_id_fk", + "tableFrom": "ModTags", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModTags_modId_Users_id_fk": { + "name": "ModTags_modId_Users_id_fk", + "tableFrom": "ModTags", + "tableTo": "Users", + "columnsFrom": ["modId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.NoteToMentions": { + "name": "NoteToMentions", + "schema": "", + "columns": { + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "NoteToMentions_noteId_userId_index": { + "name": "NoteToMentions_noteId_userId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "NoteToMentions_userId_index": { + "name": "NoteToMentions_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "NoteToMentions_noteId_Notes_id_fk": { + "name": "NoteToMentions_noteId_Notes_id_fk", + "tableFrom": "NoteToMentions", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "NoteToMentions_userId_Users_id_fk": { + "name": "NoteToMentions_userId_Users_id_fk", + "tableFrom": "NoteToMentions", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Notes": { + "name": "Notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorId": { + "name": "authorId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reblogId": { + "name": "reblogId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text/plain'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replyId": { + "name": "replyId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quoteId": { + "name": "quoteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "spoiler_text": { + "name": "spoiler_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "applicationId": { + "name": "applicationId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content_source": { + "name": "content_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": { + "Notes_authorId_Users_id_fk": { + "name": "Notes_authorId_Users_id_fk", + "tableFrom": "Notes", + "tableTo": "Users", + "columnsFrom": ["authorId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_reblogId_Notes_id_fk": { + "name": "Notes_reblogId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": ["reblogId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_replyId_Notes_id_fk": { + "name": "Notes_replyId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": ["replyId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_quoteId_Notes_id_fk": { + "name": "Notes_quoteId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": ["quoteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_applicationId_Applications_id_fk": { + "name": "Notes_applicationId_Applications_id_fk", + "tableFrom": "Notes", + "tableTo": "Applications", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Notes_uri_unique": { + "name": "Notes_uri_unique", + "nullsNotDistinct": false, + "columns": ["uri"] + } + } + }, + "public.Notifications": { + "name": "Notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notifiedId": { + "name": "notifiedId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dismissed": { + "name": "dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "Notifications_notifiedId_Users_id_fk": { + "name": "Notifications_notifiedId_Users_id_fk", + "tableFrom": "Notifications", + "tableTo": "Users", + "columnsFrom": ["notifiedId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notifications_accountId_Users_id_fk": { + "name": "Notifications_accountId_Users_id_fk", + "tableFrom": "Notifications", + "tableTo": "Users", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notifications_noteId_Notes_id_fk": { + "name": "Notifications_noteId_Notes_id_fk", + "tableFrom": "Notifications", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.OpenIdAccounts": { + "name": "OpenIdAccounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer_id": { + "name": "issuer_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "OpenIdAccounts_userId_Users_id_fk": { + "name": "OpenIdAccounts_userId_Users_id_fk", + "tableFrom": "OpenIdAccounts", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.OpenIdLoginFlows": { + "name": "OpenIdLoginFlows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issuer_id": { + "name": "issuer_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "OpenIdLoginFlows_applicationId_Applications_id_fk": { + "name": "OpenIdLoginFlows_applicationId_Applications_id_fk", + "tableFrom": "OpenIdLoginFlows", + "tableTo": "Applications", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Relationships": { + "name": "Relationships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "ownerId": { + "name": "ownerId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subjectId": { + "name": "subjectId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "following": { + "name": "following", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "showing_reblogs": { + "name": "showing_reblogs", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "notifying": { + "name": "notifying", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "blocking": { + "name": "blocking", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "muting": { + "name": "muting", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "muting_notifications": { + "name": "muting_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "requested": { + "name": "requested", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "domain_blocking": { + "name": "domain_blocking", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "endorsed": { + "name": "endorsed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Relationships_ownerId_Users_id_fk": { + "name": "Relationships_ownerId_Users_id_fk", + "tableFrom": "Relationships", + "tableTo": "Users", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Relationships_subjectId_Users_id_fk": { + "name": "Relationships_subjectId_Users_id_fk", + "tableFrom": "Relationships", + "tableTo": "Users", + "columnsFrom": ["subjectId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.RoleToUsers": { + "name": "RoleToUsers", + "schema": "", + "columns": { + "roleId": { + "name": "roleId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "RoleToUsers_roleId_Roles_id_fk": { + "name": "RoleToUsers_roleId_Roles_id_fk", + "tableFrom": "RoleToUsers", + "tableTo": "Roles", + "columnsFrom": ["roleId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "RoleToUsers_userId_Users_id_fk": { + "name": "RoleToUsers_userId_Users_id_fk", + "tableFrom": "RoleToUsers", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Roles": { + "name": "Roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Tokens": { + "name": "Tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Tokens_userId_Users_id_fk": { + "name": "Tokens_userId_Users_id_fk", + "tableFrom": "Tokens", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Tokens_applicationId_Applications_id_fk": { + "name": "Tokens_applicationId_Applications_id_fk", + "tableFrom": "Tokens", + "tableTo": "Applications", + "columnsFrom": ["applicationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.UserToPinnedNotes": { + "name": "UserToPinnedNotes", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UserToPinnedNotes_userId_noteId_index": { + "name": "UserToPinnedNotes_userId_noteId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UserToPinnedNotes_noteId_index": { + "name": "UserToPinnedNotes_noteId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "UserToPinnedNotes_userId_Users_id_fk": { + "name": "UserToPinnedNotes_userId_Users_id_fk", + "tableFrom": "UserToPinnedNotes", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "UserToPinnedNotes_noteId_Notes_id_fk": { + "name": "UserToPinnedNotes_noteId_Notes_id_fk", + "tableFrom": "UserToPinnedNotes", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Users": { + "name": "Users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verification_token": { + "name": "email_verification_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_reset_token": { + "name": "password_reset_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "endpoints": { + "name": "endpoints", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header": { + "name": "header", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_discoverable": { + "name": "is_discoverable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sanctions": { + "name": "sanctions", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instanceId": { + "name": "instanceId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "disable_automoderation": { + "name": "disable_automoderation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "Users_uri_index": { + "name": "Users_uri_index", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Users_username_index": { + "name": "Users_username_index", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Users_email_index": { + "name": "Users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Users_instanceId_Instances_id_fk": { + "name": "Users_instanceId_Instances_id_fk", + "tableFrom": "Users", + "tableTo": "Instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.VersiaObject": { + "name": "VersiaObject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "remote_id": { + "name": "remote_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "authorId": { + "name": "authorId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "extra_data": { + "name": "extra_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "extensions": { + "name": "extensions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "VersiaObject_remote_id_index": { + "name": "VersiaObject_remote_id_index", + "columns": [ + { + "expression": "remote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "VersiaObject_uri_index": { + "name": "VersiaObject_uri_index", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "VersiaObject_authorId_VersiaObject_id_fk": { + "name": "VersiaObject_authorId_VersiaObject_id_fk", + "tableFrom": "VersiaObject", + "tableTo": "VersiaObject", + "columnsFrom": ["authorId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 17236b20..275e4c17 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1724073118382, "tag": "0032_ambiguous_sue_storm", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1726491670160, + "tag": "0033_panoramic_sister_grimm", + "breakpoints": true } ] } diff --git a/drizzle/schema.ts b/drizzle/schema.ts index f8d87947..a78160de 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -61,6 +61,7 @@ export const Filters = pgTable("Filters", { }), context: text("context") .array() + .notNull() .$type< ("home" | "notifications" | "public" | "thread" | "account")[] >(), diff --git a/package.json b/package.json index 2807d73d..eec1acca 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "@sentry/bun": "^8.30.0", "@tufjs/canonical-json": "^2.0.0", "@versia/client": "^0.1.0", - "@versia/federation": "^0.1.0", + "@versia/federation": "^0.1.1-rc.0", "altcha-lib": "^1.1.0", "blurhash": "^2.0.5", "bullmq": "^5.13.0", diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 32537133..485f1af4 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -34,7 +34,7 @@ describe("API Tests", () => { body: formData, }); - expect(response.status).toBe(202); + expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain( "application/json", ); diff --git a/types/api.ts b/types/api.ts index c4b94a05..22ee2fd6 100644 --- a/types/api.ts +++ b/types/api.ts @@ -11,6 +11,7 @@ import type { Unfollow, User, } from "@versia/federation/types"; +import type { SocketAddress } from "bun"; import { z } from "zod"; import type { Application } from "~/classes/functions/application"; import type { RolePermissions } from "~/drizzle/schema"; @@ -59,6 +60,9 @@ export type HonoEnv = { application: Application | null; }; }; + Bindings: { + ip?: SocketAddress | null; + }; }; export interface ApiRouteExports {