From 5aa1c4e625bb0eb55418f33c34cf2e90ec3274c7 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Mon, 16 Sep 2024 15:29:09 +0200 Subject: [PATCH] refactor(api): :recycle: More OpenAPI refactoring work --- api/api/v1/emojis/:id/index.ts | 424 ++-- api/api/v1/instance/extended_description.ts | 48 +- api/api/v1/notifications/index.test.ts | 2 +- api/api/v1/timelines/home.test.ts | 2 +- api/api/v1/timelines/public.test.ts | 2 +- api/api/v2/filters/:id/index.test.ts | 2 +- api/api/v2/filters/:id/index.ts | 437 ++-- api/api/v2/filters/index.ts | 399 ++-- api/api/v2/instance/index.ts | 108 +- api/api/v2/media/index.ts | 167 +- api/api/v2/search/index.ts | 315 +-- api/media/:hash/:name/index.ts | 98 +- api/media/proxy/:id.ts | 123 +- api/oauth/sso/:issuer/callback/index.ts | 421 ++-- api/oauth/sso/index.ts | 168 +- api/oauth/token/index.ts | 222 +- api/objects/:id/index.ts | 160 +- api/users/:uuid/inbox/index.ts | 701 +++--- api/users/:uuid/index.ts | 111 +- api/users/:uuid/outbox/index.ts | 190 +- api/well-known/host-meta/index.ts | 25 +- api/well-known/jwks/index.ts | 54 +- api/well-known/nodeinfo/2.0/index.ts | 40 +- api/well-known/nodeinfo/index.ts | 14 +- api/well-known/openid-configuration/index.ts | 64 +- api/well-known/versia.ts | 72 +- api/well-known/webfinger/index.ts | 186 +- bun.lockb | Bin 287044 -> 286668 bytes .../0033_panoramic_sister_grimm.sql | 1 + drizzle/migrations/meta/0033_snapshot.json | 2126 +++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + drizzle/schema.ts | 1 + package.json | 2 +- tests/api/statuses.test.ts | 2 +- types/api.ts | 4 + 35 files changed, 4883 insertions(+), 1815 deletions(-) create mode 100644 drizzle/migrations/0033_panoramic_sister_grimm.sql create mode 100644 drizzle/migrations/meta/0033_snapshot.json 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 542fb7d0f9990037f4a6dfe025a05fb856ec02f3..eda57002b1ae8fc5ee54e469fa6c7524929fc431 100755 GIT binary patch delta 44814 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 delta 44295 zcmeFa2V7J~_dmS5u)>YK;)=u?J1QW8BCIu5>cZH^#kF5*#yg+<_D&(~z`HT^pPid%8=B|Q?(q4=`>hV!*4Nd-*RI*! z$|QIE#AXQcEx*N*@}Y~xQVe`dTz>-`otS9wD3sEG_f~vFe1d_G7&<0qSj=!sE4#&# z7x}{@`VAR?oH?lH4ak-9NPZpp`5`ZN5r>Sz z;QheaU}AJc!Z0-PSpiw`cJSiBSAbJ*A8Ka0s;H>943YP^>fLsdvE6C-+ zdqbvy2OyUQZvnXk__F+#WXqdK_$uICC{TV=6^K>^T7!E5uK`{L+yk5j(OSAPaX{?I z0T_*6;a_?#GHys52WKc+q04uHqso+gP-cF-98t?ek2tAt9~o4*3645bq9YRHMh&%C zCZb!E7l5t?Fn3rK zY{aV&`o>oN1Wv6-;B0MJl=twYVFMDo!6)?l=-~r~vbt7~sowycR@c;9y3|TG=U3x$ zvKC{0A@^}vIa=R?v(_)c*-B(|#E`+L&6JM;XKlN{>9oz@oOXXyka%cCSw3#)fZ;K* z11zT@Q@=H44fI>3G(v&}Vv+0Jzn`TtWb`|w2)Hp?;OzFVSaCFDL}bm_n8<{bWF7|C@zsJ*|JyVGgh$n zn7D)i{bLd>n`=o|t^jBK3lyIO&Yq3}FKDso&FzlDQAig8))kzCxk`_>dnEh#NuLcF z5E03iw)x5y`p3nN7#=eu@v+Lk15V3UA)h=QoEE2ob4k7LF9&&%vam>9X;F7@TD(Zf zNf9H)3_&{;1C0D4DV|8MqG57sS=@C;o-)bJ1EphTsp=*x2N(-0(P9}3nH`SwLHl?$ z36hH7PjFV;R=<^}vUP;6xiybi*H|vXba0OD zY;YPmEMj=H_uv6Z?5Gix1tV^+1n@jY}}BC}kozt;xiKEQK91r7wW;0P3rZVv0Wslm7l(@ipKqI3Bz@crS2vqprdW zDgHxu)m=#u}S=DfTfBMl!Fm(YJyLUP2g3jmmL6r2UHJ9!Tr5)rjWDTWS~ z6L%Rn^S3HICeeFT;t-3aA4mhf3OE-Amt4Ojcp+BSTN<4CC&1}BOpd4lSlBxt7t-I# zTeNEGP&tJf$4Ot!051s5HPisdnl%=34?QBUNAi5MLv+9(tXRATMGQ8==&SbDO_1#k zj2SXuNKE2zGvC~q%>G6sCJkkD`~daR4_~5MOvIFkp@|5>7$V@DZ|jB|{YXxEfJAWs z-&$q3nK44Hr!n9xco{g&j~gi+mI2O+Ba}RHl*~6rA{H_o6b{agwFWN&ULU+L_%YSa zR`8|V&oWgO_8PQfqOw7kdP3U;5}fpdBmxr@-L^#T74(VNp=P@%U6ZW zE}6bIeQNq#&9UUDzdDVsgduc#ziU>KyHb*z&Q!J zO_LS30Oz1z1kMIpfm0qgUDg*BhgpNys2Q@{1@J;>KcznsjGsv}r2(BF)8fX8|A7iv zVKK-Bz-P^p2ATTUdN8jnmdmr{P>w@BXQ8>TL_qe0?2Gc80Ob{T15akp&grs&uYh}aBfphLWQgYZjX}~O{@48;Djsqx1Ly~(V;XuOeV>wAigVW+s z!0D0ttK=bj0xDog+P^`@m?+59>i}K?yfO4kgLmB|BT_Ip>-9x`8SrVUzG~p~>v_!z z*@Kn)WDh4Vmt)^@zYH{q!xM&%h_zUrK#wlJ3eFB30;fk(!7GDDfzuNW!IKG;LxKhV zK!ugSe>^0I2KM7Hd$7%_^h18Qx#8CJY4Y>;Oto)aE8{hisw`O$>$RP zM)7ls?@@e>;`6}S;fadJC96a)#an^X;y}eKDPBzR!RQ$c=n2kAm(!E~T}P7tV?BX^ z##~VRzv{^KOGZca$;DeH*Q+O^<*OHy_L~y-$8Z-wXV=`43uQVuSLg_E&bvO~j6rXM zJHTs!8w*I~TU9<5mofaYF~g0mc>IuhYyZ3uzO`KmtGoNW9vA1gY0~fR*7k3@7qD*D z!)iFK7CjB`we(E957QmqPV0I-4Da9SX?QQDXX3q!?x^Xs>UtR7f6&uvI%t~9H={LIknG$bOqv~-|`92D!N%LA&}e+sUIY2yHaxB4yhp|asC%;ZM}UBciXRS zdXL&6T0?h>r7<#W`qG*K)@gcXZKw5w?(lJ13+Q2ZPt?=!eo)W!acYIJr3FIQ#n5f3 zhxt0KGxaoIr*;ZCY%Gty)HlHDraSzc+Phd0xZ+U8U5C_Nw&NS1)y1l61<9&M)DEzw z=#Dy0>mfa?j?;b}1JXthuM=diR?uQ;Yotaa^)^%1GkTc6)0$UL^LN^VF;6=a)n+3V zY1CHEFTnl_r0xdZ2H`X+8{Z1as9Ae~RL^X@2i8SRz>i ztYh@F`W%5wynE=5AWnp^Ag4A16ORLE)yr)QM9$Tq5Nku-(ZFe6j;UHlPxlGZE+fUU zwCYP&1zH_?shT17ZkV|Bb+4N65>rMleSS($3wCPN5Kh|~*sb~j+6qVT3E zjh=;iTVPStmN;x34XM4JRXa#Kfs_+?K|R7Z!0x~{T~|-{4$|5o6=>FDovdd%oz^3| zqoLDUNe{z&w4R3d9eQR%r}i6mDC*ku2>$?kH1?HNdbod(_6btHz+DYnzJb(OPY({V zS1Mz%G^JJUgP4Lw_LSB(0;y}X8!;eI&ur|pm%`G7Zvum~woDoIp&e}^q?(Z2IbStf zIq6$B!-lSqFz&En6;kZq8+t^I0Q((CZ|hn0gS6`9<=Evh7FrCX_OjL50oqPTwA;>^ zt=&_3SY5sW?m-n^vDlidJKlC``+;-y*p2$Mic$;99JY0b#CZZgK$;JUJ#;fPujXiC z?Y^m}HFsM3=$Ux`Pl7Far&V!6Fq>8%P@ato;m$JuRRwZ5HTW z*&Gr!IRO$4z(lPR;JybEyO-ni2SBoow^q}`S~<0TRkCZS8K5nM^tP z@BKhG9AFTnbE>l@>1nN<)(3iKYo``kO@=p%eyeeS^AbDne595tkgn8n@!*4DbCt<##ShqZNT*O5aHAnLRV&}w_j)#75Tz+^~4 z#!T6T6xWF>XSMbVq^8+YASMl0q0Ac%iR;3ymwP+Fz6KI3@D0+wMoPMj7L?U9+dH)* zwd5=@W`b4>#aNFKxb2-O8Me{=J@w_u9InyHkq~kTs&Gh&<~d)i!#1 z4Jm0CJ>~&};X>Ms9&}a`M!!aY_AVrtC1V|9QJB;Ad!SybM~Jn*9@fKYUkr=u7=iNw zQjLsi$~6qY?(FF4w08=EtBv5e2r1>5&H=XHgY>ICLo{C)L669dd8Cr$*q?&Lz0dHL zRy66km`|>lG*T*8E5O=Zcl32?DZo%E z+J)z>`}8zmzX6k0GqTixuV^PU(4+oJlCf^xhj#WFlw`U+AsB+LYyHJV~LISzSeBYRbIA$Ur@C%B}VR?tLJ^?3~fHuG1Z{PJ5LOjQU=& zLH5Y()JI6QCWaX680xg{(!+*2?f#v3GRcCBNIf(NQPdIV)FL~}p~b1JZ-ABtiSET} zs}W!;&_%x*7h)gR#bO!ANMZjPsRlg6*xu}_FB}$PkL=2mD$i!SkwUX-DEzvaaU8Z= z$LeYEPWyRa!HjYC0^J#UyyAlFU6HD1SU=NDS&!3pf@TvoV7G4fQQfFPVE^mj)FXZh4z8tjYHpT zq&Pd}+`a~hOHqb4ufDm%UIe7t$TPMhZFY7ZgYiX3YFHr^j<8s;zhL#;LWFIqhmCSt zXX$Bpf39baa@za#!-oNS)~F!sIX!H&)1EgHQ%TQC48r9}CgjJEam>cBwAaIuoZ80z z7RvzChs~{afW0V^1NHEvAbS#0eGS)T8Y$FhuQyQTS{Lh?W1L#SD0yhYG-@7T9}Eej zHzvq-K8k*`2Ve>t04~`a_#rZDxPXP(x z1s!JaxJE5VM$FQR4wf|-yS3H}65;_JjFq?<63fA*aN93R6Llie)*K>ZD|QR)eS;yX z{Q)y`B_s}$Y~edd3=w&aRa84RS47u_LXx4Y9Hzn-dRmH8D=^fI+MJJ#bw{dGn+uFX zhMfYI9EZdZhjri=V11@%raJ9yw;^GhVUGd>wDQAb-_Zy3FA@@4 zmv*K@;%N~}*%O|^9_pFUXqb>4 zBhcD7Nc6j5wYCis^|3n+4A34x@`Z%y58swelzum^5O_eC7J%Lx7IuGBE9}k~*>EzXoc^7H)XLDrg0K~zr7uhm(b8v`MzHEENU{K>az$$ddYNz!xJ#4j8>%2%#3pg2Lw;2-kjG$tx{GncIO^7u?4_o83FZqyFGxB_e zR2!o*6tPuF*GsJp(W27LJ05VXwiA*ZIv$HWKVpA*qKrfe4(}YKZA6MKVaM$oVE<9# zxO-M(v1vEwQVOK*W>eY~NUYVE(6%B=^o8p~w1g#c&KMyGw{0CCJGIhF%~^n$Y3-$_ z0hl%y$e*Kz5zq=PldB6Wb{XamB$|bz4Z_=8NE`y=0vabm$3~}ie2vscd#M51 zTWhli5a#xQ)E@fCLwMT+Ne&IWqOHr$gES0MTd4_YA0)1R%p@ELyw;m>g5}~M$vjHi zbMop3x_@lml*g8?&4iRwWfmkZX>2sU0k+B;^o3hOw6Pn^Ma%-XA>mBPR=HcP8 zgTyh0t1%VtK;kf9`nCvR~b!*)K?mSbKPS|vZqT^0<`e0rg3nPeLJK9x>rU6 z_iZw|W5IO7Q2|m9qXK$yEhJeTi)h<%Nu;N54bplb)lZs$iuXX03y>ap0x8G5HlLd2 zaTa_CNscr%uS1d^VP0&8#nL~!<@1obW=oZKn4V-uRzVt=-TGTQbJsEjl3XsF(UaOV(b#_AkhY6u4=O((dF`pehU&ECZ$@t&C6<7i&&Sr8{6a_8UNrdZwwKn zmU{TEAp5&W^)ym9kcu!;4fk3s?Tpk+qz35ehZ?x=lZ()pZT9w%y69dTgRFaX$3CaM zz<%S}cVmz?5GhV(gxU51_KzVM_snfK_v;G}I9(6m^9`fo50UC%r0ydXr-$!v;Chgi z(xxp)0Ya+mA$(k7q>_<}Ws}--q#B|L!z=cX!}wsv&{&zB%6EiUsL-_sAGKH#joi&h zH8-$-A=SaCsl_pAtn}{;Nc5 zuj@&}n}-^>A`xjor9M***2W^m#V4CO4$1JQt@J6q)Ugojd_C-#(`GxZFFb|=>uKqE z99WkIx@at*8y6_C5UG|%>UMUj#uvo0PBd^e66vQ^g8FV3O#SxRskH3W z*GR!r(5w2TnHrXz%E(SVN2-~j*W{v^nv$J5W2Uqsm*lY#eHQ^*7f3A(@8CSaoB5wR z?LPs-T>d=B?!3(2F*Pkam6@HYbj8d~M5?u+w+|_-5|nVuG*j)3l-Jn?u0|s33zeW& z^{b}V@a)vy>{Om>>@oF1vr}`kQ`fRn<-cN&sTY-<`ZzoFC_7a*OX=aZPS}@DZ4=}s z=si|A_NJd9an@p^$2@4GJ1#o4Dc9w_8F>TsD5U1lz%?GO&hp*RuU-tXhU$(>PU}29 z?2=Ra?1nsu;usc+dr&v^tCvEoEp*3aC$2ccE<5eVZgSbD*AKEEy=Ae4=~-7AxPHyK zOehhlc1G$DQmu_t{@X?%Ms9DULJe#^Qih)OcRejL+1hNY<7yN4WUIwu(eY*jpGV;p zZil=OII|Y1d~$cl%au&-3VAiS3-~&4*7q^E8#td;vf#k8uF=<7jKjcfU}h%ikARq?H)?@RJ;s0^~)=nkK$6Us$_2) zo`j*-eHBm_9RDqWiq{8cfnabp*huka;4Ie)oDH{DybX9^$l>5D*B6{Ga_U7W-cRBE zY|ygm8@?$M#_RzK&duq)!3rm5v%|m%B`BPn$>DfoJtGuOZqx7oUPdqdhkF<@EJ-To zb(|%~AfFwb08TlXHGrq`O>yEARsK87*VFHK=zH#zG{$m@(#*{Xy|3`xoX|A9vBv2t zpFAJrG$oVMl^-fOH)pwYg@d;;OkE6s*iyW)D=U?}8k_~zFjH~LYgPVwg_B$L@IO6_ z?rc;jIfr5!IH6A!PR>?#Df#a>hi<>pBWJn8O3uw`)e##n>}A4e@R%y_nJVx)?xKf( z=aI}(pF@k)oK^gsDn-uZ7fL3l-UTJUj+aFKJ(ZuEGb}w;crGqi)DxBQ-{BnIKcLSd z&lG>5%ID^UtXweQTsVA@=LOHN@B%J+{pW8us8Ud=UQ2Nwa9ZUHj{g>aCD#R~exTy@6>p$;2sr**_(xjg zP4UKhnz>;5uwZiqv|uh?xq&z4-vnp=*9yM_ z&Ys>?_&vq%gLA|D1)QGzUEzN!+y#Y@wB$)ff)%}4nxQgP{!CAkzl52zGzom}i$$>~vp*}b(3<75bA&NHw$A3#RCAR>lek&!1 zD!HBF9l%*nMvKQa&K@pR2!W70pK(=Q1N;SZwO8U-v+M)J_Ov(@Ewl?8yFAHF`NXBHZ1Rh zv%oCy8sK{regK?9a0r|Y9R}x%oH+l)nEbfHbMq3wvlLElY#+G+Qt6w@5^_fTUZ=X)W*~V&lUXUMQSb5G-FV`Kt4n>6=ZCt(XcdG{-PAM1c z?sE6p-P4y#JzFp)vuIQA^>=plw_YE1!qL9#(QoFKDVC9M$o(M~`Umwccys09)VVFM z^cT~6TT6=2-qzMu7m?Q6+D)|RW6dv~5wMGveE>Wmu%QnCH}RCfn!W(S`vTC!+P(m~ zM*zql0U)mkivZx>55Qgm`GwXGzzzbj{QwjcI|)Qb0`Q6iP*}u70w~!Zz!?HXg`+=! z69mTh2T)v`Brtja0Pg_+JVep}096J8$RgknRR;pNLSXtp0G=X~z?3Ke!BGIb#MCGN zfzbf&6DT95SShhAV_2q zn34b>H~~Pgn3@0}FcH9g0!~pc5y1BZ(h~tR5_btK7!DwGIDjT1Z8(4yBLF-j&`h)( z0pJON4I=0#I@cfHMR-3C9=!CkTum1E7mINnrF?0N!H(bQ4Kq@m6IVfGh%G zqUtyRR|rfW2cW0OBrs(>fZ*`}!o}3_00JifxKE&us5b$?_XN@>0EiHG2`oqk5Sk1i zQlupVXpsWo8G!+!WeR{N1U94qh!Rf;tVsnBo(dpFtW5>beIkJT69EhsVG{wkzXM<| zfmorv17HV%*mnTLiJb(Z-v!|HE`WFu^Dcmr?*TYNAW=Bp18{=C`1b&e5GM(Yo&>;q z5`a-6X%c`clL2HAND@^i1Gqw9`eXoOMJ9nMQvd`{0We-nodO_mDuDY0l106#0KO-X zJ{3T!xJzKc`v5}U2k?$admliH4*)zP@SbS-0e~k2HhchJvUo~h%`^bv(*R5rYo`I| zJ{>^*=>R?uVbcM)&j7HOz;vO_0I-8V>nfOIic z2M{LyY!!}008S7XzX-r~agxC34*__82p~fweF&gRI)E$!J4MxW09Oc1PY1AD zWD=P25rE*20PGc0KLQZA7{Gl3`$fIQ0KO-Xz8JtkahJe?B>+N~05~ktmH=q66u>hA zM@7q}0G<%ouoS>?@sz-tWdOpL0XQkvE(6egIe`4j0h|(H%K^Br0I-+98KJEJu!BJC z3IJ!tP6E*@0eGzha9+f$1WmAMNnrG90N$$sToy^I0aRH7 zAd5hzsJaHg6#~=O0JtVH2~1fFAb2f+EHQO0fWUPC?i08n>a7FtJ%RLf0B(u91Qx6Z z5V{_~ZIQMfK#Pw7JR@*NwEP&r69OAP2JoGDN?^?f0O1<|+!bp#0O-CEK>m#Y?uoFC z0NghL*h}EP&^7_sK_GS$fQMoyf#}Tuyfy=PBw{uLD7gi|83K=mV+(*21jcUx@T)jU zVDu*dygvc(FOl>KfGS%7WD$5Os%`~vg~0T!0R9k}1g2~Q5WEe*Gck1=fWYkl?h|++ z>TL(`J%RM?0IcFJfd!ue2>ld*i%9zvK#L3j&j{E>%M1Wd2yDmz;3l3DShE8__znP? zSi1v2_niRp?*x!ngzW_2z6-!!0{Mlu3&0KnvAX~i6gvq-?*`zt8$e+Zvl~FkJpj%S zC@LI#0GuE&eh+}+;v|95djWXw1>hl)_5!G~4?q?Hhp4&_z!d`1_W|$}nFOZn2N1j; zfR~uMA3)#%0QU)$5%mrL_?|%e0RZL1T>=XZ0th__pn^y{2%yCw0M7`#C0ZT=@PxpI zLjWp^rv%m<1`vK2Kvl8!Fo5ny0OUUcpt=Y<0>J$!fV~8~g?1Fc4g#@90n`#Z2}B8^Fjl^973r+(FJq@6VNIMOn z#Tfw42s9Hd&j5HrV8a;z&Bap!Yd!}M{yBh_V(sSux}OD*|15ykBJ3;x_j3UD5(pLA zIRHBd#GV7tPV6KQeI9_oB9Z>+ONWTOi zLfj>=;4*;F%K#!p+GPMOt^jyOV1Q_O1;7&m8?FF|5>E-N$pjFd2_QzS%>>Z>DuDb~ z0Sp#lR{^+R1F)AstkA9j*g+un8h|*llR)%W0KC2e5HDiB0#Gswz!?IG!jWa|W*si# z$wr8iWFtkn>tLfq64_{Ro-9dJy#Y2xq>_ylnPlUH&rPuLVk+4Laf>Wj)Vl?iB4(4N zio0YJMWe65-VteJ?}|rc?}?VT!6u2NWRt~HFtO$v7#IExjGHRfegph{VZ8(Pfd~T= z?%yJN?;T`M7uvVLXNU;0nPMl|EK&G7u-PJpY>qfcrVGdSU~@%0**tNQY`!RW7fgsG zvIXKiS(>Q&1K2{5O14O3l6@$A?t!I?sbn9CTV#txy&u7rh}mRI#a%G5;698Cy$|D- zi?sUyT08*ojKE6K@&SM+1U5VXuv$DNu;w9v@P`1_inR{`bpHuJ{+|G>7hyjEaDN0~ zFM$n0djwzyf!Id?Hi?}8teZvQpTV|>7_v{qL9(sF@fd8Ih$q`FPLh2p%KZYCA(FsE zm0zv-#8B#JJYDKV^jG(P`$tlWj8+G-R5qOY>vlw2sx=K+7s*C0*~|L*i!=!2YXuZ zHCNycqe;=Qn9Vh#q-L9D)yvi>X0%~`+o?+zR4A>^fYm8B=34t@q!zTjV|BESmzMB; zi~04H2A0#$28JOkirV(rOODUo$v61@d;Ta`DmmjpFta^v&v&9xH(ze*D2bVJ-JRyTT-hJSQvq1_opa0{t0u2<#X8@_k4~Uxz z>nFCN$?&=H8l|%Mi~>Jc=4-UV_~G$ImFB~O_-{Ola7khOs$gP#PU12Xc#T&WpU1kY zuw-yH&&L_EWV!&J;FTbs^!Y|CrA^-}ee=P>g2=)nea4Fq8M2#&K;0EK37qxvY0F*; zY zv;m*H;A@t`_@o?fZ1Oc*VGgAEv=U!)6jlmpKKeu_>%jOJD(ATIcoQG^m}h{-H}4sb zJ{ga7S_FVSE(7Ao@x_NGnJf#^l-@^5uN<(v$qHPo z!14eeBF%>YmME+O(tP}pFFst!Mk<2%fF)nc6!sR<^O5EdE>{>IZ|Mx;5Ux;IWu*Hc z%^^%?C9JRt=n@lntx{N3q`y*_`4lH&mhpHChk;LYa)=PHEWXP9`&3yzCgTr`L$hCD z-bl{?#-TYNv1EQ>gk>1Kz_B@~6l*DrLvu)BwG~zxoDXf%E*}ublS6|~Jq=g$=}j&Y z4g;Uvq@EvW9MXIpS6CgS$J-*M;t2&Z%&a|fWAS39y+D4 zK%@sU6R*?2U>Co~!qQt5{?t}9xR1)>lb}p8c1!~CbzWhN9j&n#=uqy!6u+#%FM5n zUQ=Lf7p6s;frNjEZTOZIUxy;wdmBXeFz=Qs%z#Jt5c^tTEs*9@vedt=u+j86UhYT4 z^Fy}s8POTGiq;4|L&N83UV!-cl@(+Ixqw_jb`ZlSpV8rSM|?o!8Hi73?FH=v?FU^z z<#ZXlo(W=zyaxJ8JlbLN3;qE~K4rWKv>C+UzZJ9|#O20l$TiD|#t6n|%|k(LLG47u zPFr;c10nygU_MBI7J$+~ddAY7wn%F=J~dPT@)zg`{%XZ|3jYOUPFO3*266$p+Jx_J zTMzA5=rhJ06!Ui5)_2{7BtzLm&^w@aL6bmJLGOd6gXV$eg9IoIvN_?ue~+!2qbkymVF@GFn@IEVT2D}Ek-5iKxm-RZpCapb5T8N*5p*B)0Q3{+ zXHjmit*AFwJww!X(8nM~6$WeuV+Pu}AjVei6q7{6UYkdWK}hxo^#kGTz&~`uWBOvo zUfV!BgU&7S>t5TYWIM8tp#OZt&IiOeQwKB?b8~#8|+!&j87Q$oO{%#E08=f(F|V zC59jo3F2RxYyfQnZ2`3aRt;1dR18!I^aS{B&>qlUk+t8J+=b5|9s`XAA+n8 z3PCy?w)6z?VPpo44j>N@7xXh!#~8%8%HYA6n*d4#Z5LY(*lH#7+2u~46hI8CjF=-q z!$JJ}mkiJjP!ec3Xar~^r~`;+pwZ+Y#xBMxo>OK+=6QkV10DxjgLnjBnC}nbr}$$) zcrdw=Wh4^ZlHWx^8#s4P{vE@7kN|P#>myPR+RF6f1ABZdlY0{PAZ|JFpjWxsPB-Ln z8yOEOgmeMWW270WxEFRp)h2Ow_{J*o9I|z-_Z8}92xrVBnL6ZVTQoNnG(i#L@DF14 zA)9A$7U2WDBo^t6W{-)3hio2|2rUXh@JtR1qE`H@apxI5kR@K8TfWx7;^xC~+ph*N1Ah$`=cCWH79+d33|5ByzF3W(38PXKWM<3ZdYnt<#eSCB1Z z>0#RuJJo1|i{hogJwfguH&7nMiJNje=;wo+AM^%D15wVa(q=w!rcKVjGAIcu4k`%Z zZrp|Ol}Zai#gHxpDhetBDhz4_JQd`DbO{i3D6>p%gBk-jjcO=-KC@L%t^>#qfi@t`nbQMQ19EjxRZtaBWl$whH4xW^smlgcW3OokXXMe6b-XA5RYx{AU9AG z;Edxhz#o8qq#h^>WX_+jkiG)C3_1v6hjxM(4>p5u0&N8C0A+wa1#Rc}Z$;u05EZw8 zZv(wLd6>5!_%6^s&~DIP&>od0KL9!lx&%4_x&S%}`T}$wbR2XHbOdw=bQpA$<9`kb z7W^D^8gvTu8Hkmzf-|6tpf5q!KvzMTD%}bE9_V|}UC<5Ccc7b~Z$WoJ-+;abT?bV{ zN2o(iol0E)w-k^K0=xY>ViB#eq#K2n@4haB)vgzfy#k+Of3p3 z0V)g13#tGr0;&P34yp<&1M&ow0+j^uSeqa828bh-51eJ0CYk913M(jivXM!9*eI3Q z5S0ppSfN<~F;iycG?3?Mvtsh%AXDGObL+8g<~y>>VbW63RA%qZ^qWX~<&sU@nqzA&Gd4&v z^*PpN!)6&a)EY#+HsEZ-?37twGBcUMuwhzhVrCDR|95$RrN`my0%Cc3!R#3IOM&14 zx&CvH^XnR;XL1*&^)zxI_yACUP$Z}?C=A3pxHRdN+)J1^G1K549RJ+WmqWqG%V=vx z+O|lCf>@YdGJ~rbSQ)&0LA60OLI0zO%Jp9z^tzC0PD<0j{~N|7)2;uTcyAil1LboM zUoXgga$&uZZUEwNnbZ$*1PC6*FQFpwx-rU)^@L7tFZ}0*2jl;L>H%|7dXe;Ow&=$~U&`uEkTW~V7%=(HcoavH8t82DOxV2-+h`VO% z>EcO`7s36O+gZ;J`aEz;vsruBuIW<~pLxWKJJ)Tgo@1ea%gB@+ceMu9t{hkZ1;3g; zzR;MA2MKHrd|tnZY?0W0!{%pQCGL`aBD`(_+$#cZLOLb}L-PCrRpH~MlqxHd61MI6 zY7?regSPx|+7^qceph6R`veoiVNd!p37< zRUPfuwEC7e@8`g&@L`sQ@qOGs&GIn&(76lO^7Ap2K4;@ zTEYAw;2-<<=#tgoKvNVAL{Zp~A-umq6L_%UO&13qLx`ZHKtHkndq@+-CW67@&H zN%X9Ji|&TFS~0*i`PtZ4A{~0R=lR7?-`Pr9-xTLr%van4^E7{Q`u7hSRhV!gZA4C+ zy+xVt;6C#=s~>jHnpdFJc8?qd^Vh9Qdp`Z(j$gq`IXNptEQ)%XKaV|ZRmhHsWA81_ zQTR+Og@SdT*!>;cU|J>({hOZ1u9_WJZct89E|R0;pZx>Z_N6OaPp;mAf$*tWk3&1r zUCx*D-PfIK`OD%vMvgJ)yF~uGu-g2g$lr#}pIGdTRTZFsVc>+q6FX3_elB{FT^4`* z0CrPsxo0a@)|aj}e=WONQsafY4>rqh)a}EvN_nElViV@CWqXx=mOOjicPEt%XkvjV z`~#+w)Wai#eb~fHgd#y`BKU``UAWxGf;WGr_Q;ekKkxC&Ifuzw_W*jwL=c$gm(VMQbrHWR%QCXE zBN}?D+V4di6#O4Up&%579`4cRUu!ERK>;h4YxX)m0br^`&q=#4`M&g6Q!OYd%y=hiE-5RzJjmpApxX`KwL* z_R!V_Pc1h2322ZQ^^+~s^JW!kM?%{zy|!H6*n(Zay2k5AapNcS(frx&L&^Rohy?=%Y_f-vCRNhA(kI@hHtKTVO^Suauv)vB4MHv2^B+{YhY5q)j-U8)k^k~$HX2M9;zCxU5MVrMv zvIWBai_OnJ73MMISqpvmLFJF1c9Xs0zGnUkc+*RV)4thXQtph#8eS#he?<-3#N=OW zQSkG_Um)EP`w2c46@Eomm5KyC%^w1?mP)fz0AkGapq zfsR)ch5v>6nrHO+m#u>pkD_jQ0!tffFId9F z3n=&xgaS_p<=cDw@@${2cQGr**?0|CIX@3-JE!dA+Xr%TCW_FfsCyO^xGeUb-u||G z+S@&I6c&qAC}8C;qTVLxaeXyw)?(VYVy@A&fhQgx%VBXA3jSB1z{5qM&_3c($B%+? zD!Q+7mOU9-+x0}L*qj`j@Z#QHsy^-*^Q^!s_|zYEab1h)f1>b@V)dUWyh~gon<@%E zGkSHtlncW-#>o7U_HlW_7X?pSITSyE0agPPll_0KT<>Sh#uZga8XDN~OZw!4P;q1O0$?OYuG=5OFud360@ zqQ_4eD9f`6%8qF)Pjl@j4B7M5maQKf?HLnNh7`~Dpukyi%KdJoR&75!4FwhPX3skh z|D?~mhxc{DZgmNTvpSKL5KFBtsn$B8f(?>W#M@k={LSBP_dV6|N@}y19+Muj4<%G!3Bd7Uu?)?wH_vxm2pS|H~RRIYTx)Btt?L|-OnLh{L zz2opR$Ht$#kfXOw%!C4uNb6i&eAQ7_9YjSTSC>*aP*!(!aR}eCF2(csz&VyDqM56U zpTGGl>&?dNAuH;}?6+A1Yx)_(cE7#!OrN-KYk#utdf%L!r(y%@vK8ncYTI2(mNtJ$ zzWRK>PBZ&@Y|c?MHU7F0rQ!M4b-0P81zd{dF^(g5#dy1mqptZ2@D+~dEwwRdLRuki zLjLfX#r)BF+umBg_G|jyMdV--KaWdE8_+k4xs>#DLu4$1 zSq&LD6;G;(u#rG*FL+<_g`!Fn9RJsL&yn zxubG7(GHE8DsMRp8v#Bx_ODuhAwW%^H%TGYi++Wh_c z-I@2@uZ{WWIIn5^j8QHWCT3_D6!RDFi&T%UQ0gRJ6hf>O}#qoyXA9H@D0qDM^LDM(T%=3ri3#Rn=BNJ>0&9?Ll&JqKJoI}-l{pv$f-m7BZYTf*gq8t zZ$Tky^S-@f12)#60_QZw9J7=$ze4=FeKXF@eJ(xDyiy&-xV&8#`l)%Dz1I(@(Hosf3;b;Iq=~@qgbd_olG7Ht)Ppa&A*QbDG1YFb2?{4$2x8x z;tRsMR^kJ&(%tYNZD9;wLgP~@mc-s;;VJ6DR%ah^prA_|Rln!IUon_R6JurZe8DIw z;tILgRoAn-^4bo1_D9bgXnND?>CeKutt^VBF=1)rYxS*1M4!SiTxF_mzvS!JH;BKV zt5xLR-ThJ+eogpo)ZYxhxkmH#ExaDZpIf|cm}R@A|9v8w5kZD=U$dkCogTjAu-6uR zE%>V4f7{e+GdAJ#K>ck z`_v|GH9ksjE1=8N@E#*zO9%WOp%+`&m1T{`C6ttq{KHp&=B#y!Hj zB<6~|=Y&7?jG%Nuj4g@6*Tp7E>Sob(@dxi2%@NOJ0S^aMLPcu_K-|A54c1R2J6uvr zmyVUK`8Zd+HSV3lrHs~ad4R|mD@vEbqFV!vLa^t9{&TnNxq9*jG`Lt1cXo(&rBKra z5mO4)pAhL}#;MeSzc0F1$|cnQ-wU%W_C-tMIC%`3>{j)o(B+@3Mzy@!K*KXctS74N zAtrmer26ks?kHpXx_5^M4~FHqBNU%u^Wq?E{{#Dn*Y8(RHQ>nw*+tK~3G(Xvxi(+A2UhSQ4WF?N-sVt;82!k3Bi#4uy&CtaS6KikD-#m2z^f@k0I zdm-SD86g$Moxfde&lgX5g9gEnA#8?d1O@zU*;0Q-4lftGXWU3RpO&<`)T&&1g9lJn zhv5t{6UzSeN6COSsQuSxD}MQ-ztI(=w!bdgT4b@E?!sEf#lywl5-tjJ%#E4n@Qg)` zoJA$(hUH6~Je=omb+=VEsV@Eh=Fa7HalQ`f8!7IQy>?=$aFwg8xDgv!28Z`A#FjEH zQP!ryQ5GlqZemec_{?pb9P(>nt)6z9ab+@W!aVZDJ*y_-Y*~1{H=aqP(U%I;8(O4z zYu;|fi@TjKxFY{T9aJg@||y`%vM@V%%F8gesHe$#2Y@ zj{S%)YQwh>IF`yEp_r+F7o=Z@e`}V05?SKER#rs^* za3h!O7GbRnNA4D$V4mBj$Zl18-s-Kmz8gM5;9@&Mm;{;2k2k zh5gPH?^H&+--!&e$KqaPmsHN6p;d5w*Gep{f>y%BZZOY_A4nICX;}ShKkv?YjjkA# z+!7C=;NN1J3_xzqA@A=hyRW}Am#uU|P9d~k=~Ts?`)__f?$x56f2}Z1#8-t4?&1S5 z&xg~c4H1`aeUWF}tqwUA{UP>4p|0Bu>54WrDs5d}shqE}0iR(MLk?%p(Ne=M{~F@p z2Mg*DRDOmiPz__{3k_PCy6C3(x!UzVpg}jn{>GwFH5k!J^aS(l551z$%O5|ZNvYe@ zoXL>8Nn z{lq*dTpbhOHR@_s&Jum9LtXaT^RN4MS!{rw=W$(5fDX4iBsbM|{El{c-vt((6StsX zD>6^~S>46g<~2{$u7QikBJ<=<9oXu#2fm9+jmB*T#!JNW6WIUWK=1Fx9$D1=yAG#N z6>Y-)H&TqR0h>}pTrIF^VoME7sWfp1%zwpvS!(mCkFQp{fyWD^_YvzhB8MM@eKV(N z`szz_-kFSnwYhD7lhAbkP+S6zPr&%Tdm zzuU7!d({RyjvxaCTi%bvI9~+NEg@fb?Tu{7R+8%<)g3V zEijCl#e2E<$sew2E(#BUA9{&rVPL~Wa9yy;q7Om*;Wef0;sY?xE2xWC^B?=PO+J3* zy9qq>)~&(n;r-o4OieJSt$M%RkhHVx5pz&n~G5)e*h{>70Ero)`&)w z4vC&*SH&&1`9#cwRN7;u-20+Vr60+2{S%ysP=`MkXuXx9eOtruTCvdYEv!AkCWyj; zV2i|ff}2G3K48a0e0zkzFHje+^M5W%btt#d%u4V6N(`a z6RPwou`v*r@%2}UzJ1VIXK{=5$BI9|JSVJ@ZjGvNZQ|_8w+b8OjL)>^i(2)duo((m zE^l>uQYe1yixD{$@59%6p`sh&Jk9=H+=65)vReGq*rm7~4Od$&%JxEojm52wQ0Ofl zHE=26YJ}1bT>wlI=Nn02sqhH}uu*L33wA)H)(5*FI>doJ7B2wW3at_6>Z2FFYs8W` zRN6s=Mj$sz#D{}T5UG@Ai4H+v%SCE)ux%n}7}jL|wW3f6N-P!S8o2n0S*>wfN~ZB; zCI^FY8*FF;Kw;uOj1mi*yOc6?l)s9*!nua&4p}POK3Uk)b-la_&YLv$=cZ?O)K~sN zHw`mXgHl;qmSfT=0Y{X0(+?M0AIr~q9_NYIXSqjwu4+L{R;C)vfv-YXAA&=K0{`3P zO`}EGmgwq!vB?Slyj-)AjKM0wPD{WRyh+^b?o!+prgRlcV=!RZc`w!A=f4S4&;v8& zFyZqYFhJ^(PLAU+>S-Hb&bT*3Ob4_#7!PrMF zO?8LOb-k$63TIY4hSLwB0AX|1V3(3&S}S8EBXszg7;N0x58LG3}oWNw&;x9MzS@GA#+%N5rr8)WtA#U<+4!@+26z=Wv>xBvdxzJqS=(^%6)Ph z@Q?GH;B&FfIc`$^b@?HW+$y@+zSe-Vxc!~1-EP9VmCX8!-28KD*L4`vol{2|659bS4Pd>(mx)Op9!^F zwWeIeRbPBE#*giIW-;f4+;v{@u2FUN-lo>#YbJlT{jW9nZ^l%)E_X50gT@AkBjx#M zczmX~2j;o?hz#G)M&v&*wRb`HoD~zoeCT@V zw3U5gl1C-nyX1ig4^jQU?bst{$g3Xu$2Ax%wRnb$vpgTZW(|2Z$F)FloJ}4l-krZ` zz}>mni_{{$E;?Yld8H7(nr<;sW21>GUG0Rt%>41wH;PzW$69ihM(_#IAQA^wHTAP+ z_219=mvT$@QrA3nMt)Ia#&KsDK8sc>>Xp@?qPed~_CEJ-8=TxI#g#!hr-khKqN3n`JsJI84FG?0A^d+HDPMPa`u~}L znfqX(t`kH3d!CZF2xGij)!lmKkH*HK6!(nq+&glLAw=|PvE=l-9*4^y2e*TGM<`kB z9fl3^1{8SZ({|q{&XLK_s~SadqaA}up`+MGaoJVg9XFRUr8=sN4#om3f{i8oh z79R?Ab*nlQIlKY)&5yfE9Vq+kywQ^JIeoFu#f*5|ysPxNJW_l&fB4T!oxknoGro8j z$G5g6P~{xFGkZ-@kLLf%$!R67p;^yfP$-JZYd2olCnHZmTaH4UD42j7UlWzW6xWZV z`xLH^8-r%u^Hh#|;gIS(taDH26hd!y6fT@^y?ObnMI%x^&LAF*#*>iR9sMY#bEm`DJS- z40`bYj6?MjLIKtw2OB=@me&px`^f9x&bQMeZSlR&bC@0!uPrXp2nOF9lA% zJ%K8|-JIrTIPKO!gyJ95{Y!zq*0)6QL)C;O8 z=Sdsvy8JR3xH=7(I>0Kxu^PnRzP`fDpt z0VvB{Tqq!WMB}ADLc!VT!DYa_0-bQPoW2mKoQd_p^gU%jM??U->fAtW6ZNBd)wbGN zf)*_UonfR09QS<>R0!23!}t&q{cCQCnI8P+cM-HYALv$K%Uk^6bh~n(N%}w)AP4Y& zo<2?9?)(CT3b*Nn=~AFN(E1pA-IW|!x8&X<)b&o^4^p>c`pt4?UzIt` zkcgTZb#mdu<^kySvxD$wE+jO%B~PFghW{Pc$v%=S_jpfigt7N%zyEBOA+0G;Uq z1ibOvtty#YnYN#*X6Epn{$v5O!gRA*Wu)SeFxL;LC_@5BmE!d3I2K-Rj|8aJ1gO%raV*B% U9Z=CNK+*q)m=ve;#Iqa#0KYHWz5oCK 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 {