diff --git a/api/api/v1/favourites/index.ts b/api/api/v1/favourites/index.ts index 86ffa5b8..7d52837c 100644 --- a/api/api/v1/favourites/index.ts +++ b/api/api/v1/favourites/index.ts @@ -1,15 +1,11 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, idValidator } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; import { Notes, RolePermissions } from "~/drizzle/schema"; +import { Note } from "~/packages/database-interface/note"; import { Timeline } from "~/packages/database-interface/timeline"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -35,44 +31,62 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { max_id, since_id, min_id, limit } = - context.req.valid("query"); - - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const { objects: favourites, link } = - await Timeline.getNoteTimeline( - and( - max_id ? lt(Notes.id, max_id) : undefined, - since_id ? gte(Notes.id, since_id) : undefined, - min_id ? gt(Notes.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`, - ), - limit, - context.req.url, - user?.id, - ); - - return context.json( - await Promise.all( - favourites.map(async (note) => note.toApi(user)), - ), - 200, - { - Link: link, +const route = createRoute({ + method: "get", + path: "/api/v1/favourites", + summary: "Get favourites", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Favourites", + content: { + "application/json": { + schema: z.array(Note.schema), }, - ); + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { max_id, since_id, min_id, limit } = context.req.valid("query"); + + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const { objects: favourites, link } = await Timeline.getNoteTimeline( + and( + max_id ? lt(Notes.id, max_id) : undefined, + since_id ? gte(Notes.id, since_id) : undefined, + min_id ? gt(Notes.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`, + ), + limit, + context.req.url, + user?.id, + ); + + return context.json( + await Promise.all(favourites.map(async (note) => note.toApi(user))), + 200, + { + Link: link, + }, + ); + }), ); diff --git a/api/api/v1/follow_requests/:account_id/authorize.ts b/api/api/v1/follow_requests/:account_id/authorize.ts index 6a89c5db..fc6f61b3 100644 --- a/api/api/v1/follow_requests/:account_id/authorize.ts +++ b/api/api/v1/follow_requests/:account_id/authorize.ts @@ -1,10 +1,11 @@ -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 { z } from "zod"; import { sendFollowAccept } from "~/classes/functions/user"; import { RolePermissions } from "~/drizzle/schema"; 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"], @@ -27,49 +28,79 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const { account_id } = context.req.valid("param"); - - const account = await User.fromId(account_id); - - if (!account) { - return context.json({ error: "Account not found" }, 404); - } - - const oppositeRelationship = await Relationship.fromOwnerAndSubject( - account, - user, - ); - - await oppositeRelationship.update({ - requested: false, - following: true, - }); - - const foundRelationship = await Relationship.fromOwnerAndSubject( - user, - account, - ); - - // Check if accepting remote follow - if (account.isRemote()) { - // Federate follow accept - await sendFollowAccept(account, user); - } - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/follow_requests/{account_id}/authorize", + summary: "Authorize follow request", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Account not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const { account_id } = context.req.valid("param"); + + const account = await User.fromId(account_id); + + if (!account) { + return context.json({ error: "Account not found" }, 404); + } + + const oppositeRelationship = await Relationship.fromOwnerAndSubject( + account, + user, + ); + + await oppositeRelationship.update({ + requested: false, + following: true, + }); + + const foundRelationship = await Relationship.fromOwnerAndSubject( + user, + account, + ); + + // Check if accepting remote follow + if (account.isRemote()) { + // Federate follow accept + await sendFollowAccept(account, user); + } + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/api/api/v1/follow_requests/:account_id/reject.ts b/api/api/v1/follow_requests/:account_id/reject.ts index e675de4e..ef1a7bd8 100644 --- a/api/api/v1/follow_requests/:account_id/reject.ts +++ b/api/api/v1/follow_requests/:account_id/reject.ts @@ -1,10 +1,11 @@ -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 { z } from "zod"; import { sendFollowReject } from "~/classes/functions/user"; import { RolePermissions } from "~/drizzle/schema"; 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"], @@ -27,49 +28,79 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const { account_id } = context.req.valid("param"); - - const account = await User.fromId(account_id); - - if (!account) { - return context.json({ error: "Account not found" }, 404); - } - - const oppositeRelationship = await Relationship.fromOwnerAndSubject( - account, - user, - ); - - await oppositeRelationship.update({ - requested: false, - following: false, - }); - - const foundRelationship = await Relationship.fromOwnerAndSubject( - user, - account, - ); - - // Check if rejecting remote follow - if (account.isRemote()) { - // Federate follow reject - await sendFollowReject(account, user); - } - - return context.json(foundRelationship.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/follow_requests/{account_id}/reject", + summary: "Reject follow request", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Relationship", + content: { + "application/json": { + schema: Relationship.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Account not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const { account_id } = context.req.valid("param"); + + const account = await User.fromId(account_id); + + if (!account) { + return context.json({ error: "Account not found" }, 404); + } + + const oppositeRelationship = await Relationship.fromOwnerAndSubject( + account, + user, + ); + + await oppositeRelationship.update({ + requested: false, + following: false, + }); + + const foundRelationship = await Relationship.fromOwnerAndSubject( + user, + account, + ); + + // Check if rejecting remote follow + if (account.isRemote()) { + // Federate follow reject + await sendFollowReject(account, user); + } + + return context.json(foundRelationship.toApi(), 200); + }), ); diff --git a/api/api/v1/follow_requests/index.ts b/api/api/v1/follow_requests/index.ts index 25d8ba49..30382012 100644 --- a/api/api/v1/follow_requests/index.ts +++ b/api/api/v1/follow_requests/index.ts @@ -1,15 +1,11 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, idValidator } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; import { RolePermissions, Users } from "~/drizzle/schema"; import { Timeline } from "~/packages/database-interface/timeline"; +import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -35,41 +31,62 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { max_id, since_id, min_id, limit } = - context.req.valid("query"); - - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const { objects: followRequests, link } = - await Timeline.getUserTimeline( - and( - max_id ? lt(Users.id, max_id) : undefined, - since_id ? gte(Users.id, since_id) : undefined, - min_id ? gt(Users.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`, - ), - limit, - context.req.url, - ); - - return context.json( - followRequests.map((u) => u.toApi()), - 200, - { - Link: link, +const route = createRoute({ + method: "get", + path: "/api/v1/follow_requests", + summary: "Get follow requests", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Follow requests", + content: { + "application/json": { + schema: z.array(User.schema), }, - ); + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { max_id, since_id, min_id, limit } = context.req.valid("query"); + + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const { objects: followRequests, link } = + await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`, + ), + limit, + context.req.url, + ); + + return context.json( + followRequests.map((u) => u.toApi()), + 200, + { + Link: link, + }, + ); + }), ); diff --git a/api/api/v1/frontend/config/index.ts b/api/api/v1/frontend/config/index.ts index f5383c27..c5ea4c98 100644 --- a/api/api/v1/frontend/config/index.ts +++ b/api/api/v1/frontend/config/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,8 +14,24 @@ export const meta = applyConfig({ route: "/api/v1/frontend/config", }); +const route = createRoute({ + method: "get", + path: "/api/v1/frontend/config", + summary: "Get frontend config", + responses: { + 200: { + description: "Frontend config", + content: { + "application/json": { + schema: z.record(z.string(), z.any()).default({}), + }, + }, + }, + }, +}); + export default apiRoute((app) => - app.on(meta.allowedMethods, meta.route, (context) => { - return context.json(config.frontend.settings); + app.openapi(route, (context) => { + return context.json(config.frontend.settings, 200); }), ); diff --git a/api/api/v1/instance/index.ts b/api/api/v1/instance/index.ts index 399c13b7..386f53b3 100644 --- a/api/api/v1/instance/index.ts +++ b/api/api/v1/instance/index.ts @@ -1,5 +1,6 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { proxyUrl } from "@/response"; +import { createRoute, z } from "@hono/zod-openapi"; import { and, eq, isNull } from "drizzle-orm"; import { Users } from "~/drizzle/schema"; import manifest from "~/package.json"; @@ -20,87 +21,100 @@ export const meta = applyConfig({ }, }); -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - auth(meta.auth, meta.permissions), - async (context) => { - // Get software version from package.json - const version = manifest.version; - - const statusCount = await Note.getCount(); - - const userCount = await User.getCount(); - - const contactAccount = await User.fromSql( - and(isNull(Users.instanceId), eq(Users.isAdmin, true)), - ); - - const knownDomainsCount = await Instance.getCount(); - - // TODO: fill in more values - return context.json({ - approval_required: false, - configuration: { - polls: { - max_characters_per_option: - config.validation.max_poll_option_size, - max_expiration: config.validation.max_poll_duration, - max_options: config.validation.max_poll_options, - min_expiration: config.validation.min_poll_duration, - }, - statuses: { - characters_reserved_per_url: 0, - max_characters: config.validation.max_note_size, - max_media_attachments: - config.validation.max_media_attachments, - }, +const route = createRoute({ + method: "get", + path: "/api/v1/instance", + summary: "Get instance information", + middleware: [auth(meta.auth)], + responses: { + 200: { + description: "Instance information", + content: { + // TODO: Add schemas for this response + "application/json": { + schema: z.any(), }, - description: config.instance.description, - email: "", - invites_enabled: false, - registrations: config.signups.registration, - languages: ["en"], - rules: config.signups.rules.map((r, index) => ({ - id: String(index), - text: r, - })), - stats: { - domain_count: knownDomainsCount, - status_count: statusCount, - user_count: userCount, - }, - thumbnail: proxyUrl(config.instance.logo), - banner: proxyUrl(config.instance.banner), - title: config.instance.name, - uri: config.http.base_url, - urls: { - streaming_api: "", - }, - version: "4.3.0-alpha.3+glitch", - versia_version: version, - sso: { - forced: false, - providers: config.oidc.providers.map((p) => ({ - name: p.name, - icon: proxyUrl(p.icon) || undefined, - id: p.id, - })), - }, - contact_account: contactAccount?.toApi() || undefined, - } satisfies Record & { - banner: string | null; - versia_version: string; - sso: { - forced: boolean; - providers: { - id: string; - name: string; - icon?: string; - }[]; - }; - }); + }, }, - ), + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + // Get software version from package.json + const version = manifest.version; + + const statusCount = await Note.getCount(); + + const userCount = await User.getCount(); + + const contactAccount = await User.fromSql( + and(isNull(Users.instanceId), eq(Users.isAdmin, true)), + ); + + const knownDomainsCount = await Instance.getCount(); + + // TODO: fill in more values + return context.json({ + approval_required: false, + configuration: { + polls: { + max_characters_per_option: + config.validation.max_poll_option_size, + max_expiration: config.validation.max_poll_duration, + max_options: config.validation.max_poll_options, + min_expiration: config.validation.min_poll_duration, + }, + statuses: { + characters_reserved_per_url: 0, + max_characters: config.validation.max_note_size, + max_media_attachments: + config.validation.max_media_attachments, + }, + }, + description: config.instance.description, + email: "", + invites_enabled: false, + registrations: config.signups.registration, + languages: ["en"], + rules: config.signups.rules.map((r, index) => ({ + id: String(index), + text: r, + })), + stats: { + domain_count: knownDomainsCount, + status_count: statusCount, + user_count: userCount, + }, + thumbnail: proxyUrl(config.instance.logo), + banner: proxyUrl(config.instance.banner), + title: config.instance.name, + uri: config.http.base_url, + urls: { + streaming_api: "", + }, + version: "4.3.0-alpha.3+glitch", + versia_version: version, + sso: { + forced: false, + providers: config.oidc.providers.map((p) => ({ + name: p.name, + icon: proxyUrl(p.icon) || undefined, + id: p.id, + })), + }, + contact_account: contactAccount?.toApi() || undefined, + } satisfies Record & { + banner: string | null; + versia_version: string; + sso: { + forced: boolean; + providers: { + id: string; + name: string; + icon?: string; + }[]; + }; + }); + }), ); diff --git a/api/api/v1/instance/privacy_policy.ts b/api/api/v1/instance/privacy_policy.ts index e9a5902e..17c99ae5 100644 --- a/api/api/v1/instance/privacy_policy.ts +++ b/api/api/v1/instance/privacy_policy.ts @@ -1,5 +1,6 @@ import { apiRoute, applyConfig, auth } 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,36 @@ 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.privacy_policy_path ?? "", - "This instance has not provided any privacy policy.", - ); - - return context.json({ - updated_at: lastModified.toISOString(), - content, - }); +const route = createRoute({ + method: "get", + path: "/api/v1/instance/privacy_policy", + summary: "Get instance privacy policy", + middleware: [auth(meta.auth)], + responses: { + 200: { + description: "Instance privacy policy", + content: { + "application/json": { + schema: z.object({ + updated_at: z.string(), + content: z.string(), + }), + }, + }, }, - ), + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { content, lastModified } = await renderMarkdownInPath( + config.instance.privacy_policy_path ?? "", + "This instance has not provided any privacy policy.", + ); + + return context.json({ + updated_at: lastModified.toISOString(), + content, + }); + }), ); diff --git a/api/api/v1/instance/rules.ts b/api/api/v1/instance/rules.ts index 4fa56a6f..f6beb86a 100644 --- a/api/api/v1/instance/rules.ts +++ b/api/api/v1/instance/rules.ts @@ -1,4 +1,5 @@ import { apiRoute, applyConfig, auth } from "@/api"; +import { createRoute, z } from "@hono/zod-openapi"; import { config } from "~/packages/config-manager"; export const meta = applyConfig({ @@ -13,19 +14,37 @@ export const meta = applyConfig({ }, }); -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - auth(meta.auth, meta.permissions), - async (context) => { - return context.json( - config.signups.rules.map((rule, index) => ({ - id: String(index), - text: rule, - hint: "", - })), - ); +const route = createRoute({ + method: "get", + path: "/api/v1/instance/rules", + summary: "Get instance rules", + middleware: [auth(meta.auth)], + responses: { + 200: { + description: "Instance rules", + content: { + "application/json": { + schema: z.array( + z.object({ + id: z.string(), + text: z.string(), + hint: z.string(), + }), + ), + }, + }, }, - ), + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + return context.json( + config.signups.rules.map((rule, index) => ({ + id: String(index), + text: rule, + hint: "", + })), + ); + }), ); diff --git a/api/api/v1/instance/tos.ts b/api/api/v1/instance/tos.ts index 6d53f1fa..4ab0ebb7 100644 --- a/api/api/v1/instance/tos.ts +++ b/api/api/v1/instance/tos.ts @@ -1,5 +1,6 @@ import { apiRoute, applyConfig, auth } 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,36 @@ 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.tos_path ?? "", - "This instance has not provided any terms of service.", - ); - - return context.json({ - updated_at: lastModified.toISOString(), - content, - }); +const route = createRoute({ + method: "get", + path: "/api/v1/instance/tos", + summary: "Get instance terms of service", + middleware: [auth(meta.auth)], + responses: { + 200: { + description: "Instance terms of service", + content: { + "application/json": { + schema: z.object({ + updated_at: z.string(), + content: z.string(), + }), + }, + }, }, - ), + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { content, lastModified } = await renderMarkdownInPath( + config.instance.tos_path ?? "", + "This instance has not provided any terms of service.", + ); + + return context.json({ + updated_at: lastModified.toISOString(), + content, + }); + }), ); diff --git a/api/api/v1/markers/index.ts b/api/api/v1/markers/index.ts index 4791f3df..58eed8de 100644 --- a/api/api/v1/markers/index.ts +++ b/api/api/v1/markers/index.ts @@ -1,16 +1,11 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, idValidator } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import type { Marker as ApiMarker } from "@versia/client/types"; import { and, count, eq } from "drizzle-orm"; import { z } from "zod"; import { db } from "~/drizzle/db"; import { Markers, RolePermissions } from "~/drizzle/schema"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET", "POST"], @@ -29,191 +24,255 @@ export const meta = applyConfig({ }); export const schemas = { - query: z.object({ - "timeline[]": z - .array(z.enum(["home", "notifications"])) - .max(2) - .or(z.enum(["home", "notifications"])) + markers: z.object({ + home: z + .object({ + last_read_id: z.string().regex(idValidator), + version: z.number(), + updated_at: z.string(), + }) + .nullable() + .optional(), + notifications: z + .object({ + last_read_id: z.string().regex(idValidator), + version: z.number(), + updated_at: z.string(), + }) + .nullable() .optional(), - "home[last_read_id]": z.string().regex(idValidator).optional(), - "notifications[last_read_id]": z.string().regex(idValidator).optional(), }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { "timeline[]": timelines } = context.req.valid("query"); - const { user } = context.get("auth"); - - const timeline = Array.isArray(timelines) ? timelines : []; - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - switch (context.req.method) { - case "GET": { - if (!timeline) { - return context.json({}); - } - - const markers: ApiMarker = { - home: undefined, - notifications: undefined, - }; - - if (timeline.includes("home")) { - const found = await db.query.Markers.findFirst({ - where: (marker, { and, eq }) => - and( - eq(marker.userId, user.id), - eq(marker.timeline, "home"), - ), - }); - - const totalCount = await db - .select({ - count: count(), - }) - .from(Markers) - .where( - and( - eq(Markers.userId, user.id), - eq(Markers.timeline, "home"), - ), - ); - - if (found?.noteId) { - markers.home = { - last_read_id: found.noteId, - version: totalCount[0].count, - updated_at: new Date( - found.createdAt, - ).toISOString(), - }; - } - } - - if (timeline.includes("notifications")) { - const found = await db.query.Markers.findFirst({ - where: (marker, { and, eq }) => - and( - eq(marker.userId, user.id), - eq(marker.timeline, "notifications"), - ), - }); - - const totalCount = await db - .select({ - count: count(), - }) - .from(Markers) - .where( - and( - eq(Markers.userId, user.id), - eq(Markers.timeline, "notifications"), - ), - ); - - if (found?.notificationId) { - markers.notifications = { - last_read_id: found.notificationId, - version: totalCount[0].count, - updated_at: new Date( - found.createdAt, - ).toISOString(), - }; - } - } - - return context.json(markers); - } - - case "POST": { - const { - "home[last_read_id]": homeId, - "notifications[last_read_id]": notificationsId, - } = context.req.valid("query"); - - const markers: ApiMarker = { - home: undefined, - notifications: undefined, - }; - - if (homeId) { - const insertedMarker = ( - await db - .insert(Markers) - .values({ - userId: user.id, - timeline: "home", - noteId: homeId, - }) - .returning() - )[0]; - - const totalCount = await db - .select({ - count: count(), - }) - .from(Markers) - .where( - and( - eq(Markers.userId, user.id), - eq(Markers.timeline, "home"), - ), - ); - - markers.home = { - last_read_id: homeId, - version: totalCount[0].count, - updated_at: new Date( - insertedMarker.createdAt, - ).toISOString(), - }; - } - - if (notificationsId) { - const insertedMarker = ( - await db - .insert(Markers) - .values({ - userId: user.id, - timeline: "notifications", - notificationId: notificationsId, - }) - .returning() - )[0]; - - const totalCount = await db - .select({ - count: count(), - }) - .from(Markers) - .where( - and( - eq(Markers.userId, user.id), - eq(Markers.timeline, "notifications"), - ), - ); - - markers.notifications = { - last_read_id: notificationsId, - version: totalCount[0].count, - updated_at: new Date( - insertedMarker.createdAt, - ).toISOString(), - }; - } - - return context.json(markers); - } - } +const routeGet = createRoute({ + method: "get", + path: "/api/v1/markers", + summary: "Get markers", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: z.object({ + "timeline[]": z + .array(z.enum(["home", "notifications"])) + .max(2) + .or(z.enum(["home", "notifications"])) + .optional(), + }), + }, + responses: { + 200: { + description: "Markers", + content: { + "application/json": { + schema: schemas.markers, + }, + }, }, - ), -); + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routePost = createRoute({ + method: "post", + path: "/api/v1/markers", + summary: "Update markers", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: z.object({ + "home[last_read_id]": z.string().regex(idValidator).optional(), + "notifications[last_read_id]": z + .string() + .regex(idValidator) + .optional(), + }), + }, + responses: { + 200: { + description: "Markers", + content: { + "application/json": { + schema: schemas.markers, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => { + app.openapi(routeGet, async (context) => { + const { "timeline[]": timelines } = context.req.valid("query"); + const { user } = context.get("auth"); + + const timeline = Array.isArray(timelines) ? timelines : []; + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + if (!timeline) { + return context.json({}, 200); + } + + const markers: ApiMarker = { + home: undefined, + notifications: undefined, + }; + + if (timeline.includes("home")) { + const found = await db.query.Markers.findFirst({ + where: (marker, { and, eq }) => + and( + eq(marker.userId, user.id), + eq(marker.timeline, "home"), + ), + }); + + const totalCount = await db + .select({ + count: count(), + }) + .from(Markers) + .where( + and( + eq(Markers.userId, user.id), + eq(Markers.timeline, "home"), + ), + ); + + if (found?.noteId) { + markers.home = { + last_read_id: found.noteId, + version: totalCount[0].count, + updated_at: new Date(found.createdAt).toISOString(), + }; + } + } + + if (timeline.includes("notifications")) { + const found = await db.query.Markers.findFirst({ + where: (marker, { and, eq }) => + and( + eq(marker.userId, user.id), + eq(marker.timeline, "notifications"), + ), + }); + + const totalCount = await db + .select({ + count: count(), + }) + .from(Markers) + .where( + and( + eq(Markers.userId, user.id), + eq(Markers.timeline, "notifications"), + ), + ); + + if (found?.notificationId) { + markers.notifications = { + last_read_id: found.notificationId, + version: totalCount[0].count, + updated_at: new Date(found.createdAt).toISOString(), + }; + } + } + + return context.json(markers, 200); + }); + + app.openapi(routePost, async (context) => { + const { + "home[last_read_id]": homeId, + "notifications[last_read_id]": notificationsId, + } = context.req.valid("query"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const markers: ApiMarker = { + home: undefined, + notifications: undefined, + }; + + if (homeId) { + const insertedMarker = ( + await db + .insert(Markers) + .values({ + userId: user.id, + timeline: "home", + noteId: homeId, + }) + .returning() + )[0]; + + const totalCount = await db + .select({ + count: count(), + }) + .from(Markers) + .where( + and( + eq(Markers.userId, user.id), + eq(Markers.timeline, "home"), + ), + ); + + markers.home = { + last_read_id: homeId, + version: totalCount[0].count, + updated_at: new Date(insertedMarker.createdAt).toISOString(), + }; + } + + if (notificationsId) { + const insertedMarker = ( + await db + .insert(Markers) + .values({ + userId: user.id, + timeline: "notifications", + notificationId: notificationsId, + }) + .returning() + )[0]; + + const totalCount = await db + .select({ + count: count(), + }) + .from(Markers) + .where( + and( + eq(Markers.userId, user.id), + eq(Markers.timeline, "notifications"), + ), + ); + + markers.notifications = { + last_read_id: notificationsId, + version: totalCount[0].count, + updated_at: new Date(insertedMarker.createdAt).toISOString(), + }; + } + + return context.json(markers, 200); + }); +}); diff --git a/api/api/v1/media/:id/index.ts b/api/api/v1/media/:id/index.ts index 9cd8a1c6..f87908e8 100644 --- a/api/api/v1/media/:id/index.ts +++ b/api/api/v1/media/:id/index.ts @@ -1,16 +1,11 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; 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: ["GET", "PUT"], @@ -30,7 +25,7 @@ export const meta = applyConfig({ export const schemas = { param: z.object({ - id: z.string(), + id: z.string().uuid(), }), form: z.object({ thumbnail: z.instanceof(File).optional(), @@ -42,69 +37,132 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - zValidator("form", schemas.form, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id } = context.req.valid("param"); - - if (!id.match(idValidator)) { - return context.json( - { error: "Invalid ID, must be of type UUIDv7" }, - 404, - ); - } - - const attachment = await Attachment.fromId(id); - - if (!attachment) { - return context.json({ error: "Media not found" }, 404); - } - - switch (context.req.method) { - case "GET": { - if (attachment.data.url) { - return context.json(attachment.toApi()); - } - return context.newResponse(null, 206); - } - case "PUT": { - const { description, thumbnail } = - context.req.valid("form"); - - let thumbnailUrl = attachment.data.thumbnailUrl; - - const mediaManager = new MediaManager(config); - - if (thumbnail) { - const { path } = await mediaManager.addFile(thumbnail); - thumbnailUrl = Attachment.getUrl(path); - } - - const descriptionText = - description || attachment.data.description; - - if ( - descriptionText !== attachment.data.description || - thumbnailUrl !== attachment.data.thumbnailUrl - ) { - await attachment.update({ - description: descriptionText, - thumbnailUrl, - }); - - return context.json(attachment.toApi()); - } - - return context.json(attachment.toApi()); - } - } - - return context.json({ error: "Method not allowed" }, 405); +const routePut = createRoute({ + method: "put", + path: "/api/v1/media/{id}", + summary: "Update media", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + body: { + content: { + "multipart/form-data": { + schema: schemas.form, + }, + }, }, - ), -); + }, + responses: { + 204: { + description: "Media updated", + content: { + "application/json": { + schema: Attachment.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Media not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routeGet = createRoute({ + method: "get", + path: "/api/v1/media/{id}", + summary: "Get media", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Media", + content: { + "application/json": { + schema: Attachment.schema, + }, + }, + }, + 404: { + description: "Media not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => { + app.openapi(routePut, async (context) => { + const { id } = context.req.valid("param"); + + const attachment = await Attachment.fromId(id); + + if (!attachment) { + return context.json({ error: "Media not found" }, 404); + } + + const { description, thumbnail } = context.req.valid("form"); + + let thumbnailUrl = attachment.data.thumbnailUrl; + + const mediaManager = new MediaManager(config); + + if (thumbnail) { + const { path } = await mediaManager.addFile(thumbnail); + thumbnailUrl = Attachment.getUrl(path); + } + + const descriptionText = description || attachment.data.description; + + if ( + descriptionText !== attachment.data.description || + thumbnailUrl !== attachment.data.thumbnailUrl + ) { + await attachment.update({ + description: descriptionText, + thumbnailUrl, + }); + + return context.json(attachment.toApi(), 204); + } + + return context.json(attachment.toApi(), 204); + }); + + app.openapi(routeGet, async (context) => { + const { id } = context.req.valid("param"); + + const attachment = await Attachment.fromId(id); + + if (!attachment) { + return context.json({ error: "Media not found" }, 404); + } + + return context.json(attachment.toApi(), 200); + }); +}); diff --git a/api/api/v1/media/index.ts b/api/api/v1/media/index.ts index 0053ea47..a0594787 100644 --- a/api/api/v1/media/index.ts +++ b/api/api/v1/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,68 +36,112 @@ export const schemas = { }), }; -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 - - return context.json(newAttachment.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/media", + summary: "Upload media", + middleware: [auth(meta.auth, meta.permissions)], + request: { + body: { + content: { + "multipart/form-data": { + schema: schemas.form, + }, + }, }, - ), + }, + responses: { + 200: { + description: "Attachment", + content: { + "application/json": { + schema: Attachment.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 413: { + description: "File too large", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 415: { + description: "Disallowed file type", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, 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: "Disallowed 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/v1/mutes/index.ts b/api/api/v1/mutes/index.ts index 165d06c0..029639e0 100644 --- a/api/api/v1/mutes/index.ts +++ b/api/api/v1/mutes/index.ts @@ -1,15 +1,11 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, idValidator } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; import { RolePermissions, Users } from "~/drizzle/schema"; import { Timeline } from "~/packages/database-interface/timeline"; +import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -36,39 +32,60 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { max_id, since_id, limit, min_id } = - context.req.valid("query"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const { objects: mutes, link } = await Timeline.getUserTimeline( - and( - max_id ? lt(Users.id, max_id) : undefined, - since_id ? gte(Users.id, since_id) : undefined, - min_id ? gt(Users.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`, - ), - limit, - context.req.url, - ); - - return context.json( - mutes.map((u) => u.toApi()), - 200, - { - Link: link, +const route = createRoute({ + method: "get", + path: "/api/v1/mutes", + summary: "Get muted users", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Muted users", + content: { + "application/json": { + schema: z.array(User.schema), }, - ); + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { max_id, since_id, limit, min_id } = context.req.valid("query"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const { objects: mutes, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`, + ), + limit, + context.req.url, + ); + + return context.json( + mutes.map((u) => u.toApi()), + 200, + { + Link: link, + }, + ); + }), ); diff --git a/packages/database-interface/attachment.ts b/packages/database-interface/attachment.ts index 5c5c915a..4b45d82f 100644 --- a/packages/database-interface/attachment.ts +++ b/packages/database-interface/attachment.ts @@ -1,8 +1,5 @@ import { proxyUrl } from "@/response"; -import type { - AsyncAttachment as ApiAsyncAttachment, - Attachment as ApiAttachment, -} from "@versia/client/types"; +import type { Attachment as ApiAttachment } from "@versia/client/types"; import type { ContentFormat } from "@versia/federation/types"; import { type InferInsertModel, @@ -212,7 +209,7 @@ export class Attachment extends BaseInterface { }; } - public toApi(): ApiAttachment | ApiAsyncAttachment { + public toApi(): ApiAttachment { return { id: this.data.id, type: this.getMastodonType(),