diff --git a/api/api/v1/accounts/familiar_followers/index.ts b/api/api/v1/accounts/familiar_followers/index.ts index 510ca003..2fb25835 100644 --- a/api/api/v1/accounts/familiar_followers/index.ts +++ b/api/api/v1/accounts/familiar_followers/index.ts @@ -1,10 +1,11 @@ -import { apiRoute, applyConfig, auth, handleZodError, qsQuery } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, qsQuery } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "~/drizzle/db"; import { RolePermissions, Users } from "~/drizzle/schema"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -28,70 +29,93 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - qsQuery(), - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { user: self } = context.get("auth"); - const { id: ids } = context.req.valid("query"); - - if (!self) { - return context.json({ error: "Unauthorized" }, 401); - } - - const idFollowerRelationships = - await db.query.Relationships.findMany({ - columns: { - ownerId: true, - }, - where: (relationship, { inArray, and, eq }) => - and( - inArray( - relationship.subjectId, - Array.isArray(ids) ? ids : [ids], - ), - eq(relationship.following, true), - ), - }); - - if (idFollowerRelationships.length === 0) { - return context.json([]); - } - - // Find users that you follow in idFollowerRelationships - const relevantRelationships = await db.query.Relationships.findMany( - { - columns: { - subjectId: true, - }, - where: (relationship, { inArray, and, eq }) => - and( - eq(relationship.ownerId, self.id), - inArray( - relationship.subjectId, - idFollowerRelationships.map((f) => f.ownerId), - ), - eq(relationship.following, true), - ), +const route = createRoute({ + method: "get", + path: "/api/v1/accounts/familiar_followers", + summary: "Get familiar followers", + description: + "Obtain a list of all accounts that follow a given account, filtered for accounts you follow.", + middleware: [auth(meta.auth, meta.permissions), qsQuery()], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Familiar followers", + content: { + "application/json": { + schema: z.array(User.schema), }, - ); - - if (relevantRelationships.length === 0) { - return context.json([]); - } - - const finalUsers = await User.manyFromSql( - inArray( - Users.id, - relevantRelationships.map((r) => r.subjectId), - ), - ); - - return context.json(finalUsers.map((o) => o.toApi())); + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { user: self } = context.get("auth"); + const { id: ids } = context.req.valid("query"); + + if (!self) { + return context.json({ error: "Unauthorized" }, 401); + } + + const idFollowerRelationships = await db.query.Relationships.findMany({ + columns: { + ownerId: true, + }, + where: (relationship, { inArray, and, eq }) => + and( + inArray( + relationship.subjectId, + Array.isArray(ids) ? ids : [ids], + ), + eq(relationship.following, true), + ), + }); + + if (idFollowerRelationships.length === 0) { + return context.json([], 200); + } + + // Find users that you follow in idFollowerRelationships + const relevantRelationships = await db.query.Relationships.findMany({ + columns: { + subjectId: true, + }, + where: (relationship, { inArray, and, eq }) => + and( + eq(relationship.ownerId, self.id), + inArray( + relationship.subjectId, + idFollowerRelationships.map((f) => f.ownerId), + ), + eq(relationship.following, true), + ), + }); + + if (relevantRelationships.length === 0) { + return context.json([], 200); + } + + const finalUsers = await User.manyFromSql( + inArray( + Users.id, + relevantRelationships.map((r) => r.subjectId), + ), + ); + + return context.json( + finalUsers.map((o) => o.toApi()), + 200, + ); + }), ); diff --git a/api/api/v1/accounts/id/index.ts b/api/api/v1/accounts/id/index.ts index 8f470875..f731cb1b 100644 --- a/api/api/v1/accounts/id/index.ts +++ b/api/api/v1/accounts/id/index.ts @@ -1,9 +1,10 @@ -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 { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { RolePermissions, Users } from "~/drizzle/schema"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -27,24 +28,47 @@ 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 { username } = context.req.valid("query"); - - const user = await User.fromSql( - and(eq(Users.username, username), isNull(Users.instanceId)), - ); - - if (!user) { - return context.json({ error: "User not found" }, 404); - } - - return context.json(user.toApi()); +const route = createRoute({ + method: "get", + path: "/api/v1/accounts/id", + summary: "Get account by username", + description: "Get an account by username", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Account", + content: { + "application/json": { + schema: User.schema, + }, + }, }, - ), + 404: { + description: "Not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { username } = context.req.valid("query"); + + const user = await User.fromSql( + and(eq(Users.username, username), isNull(Users.instanceId)), + ); + + if (!user) { + return context.json({ error: "User not found" }, 404); + } + + return context.json(user.toApi(), 200); + }), ); diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts index 920eae75..2736d913 100644 --- a/api/api/v1/accounts/index.ts +++ b/api/api/v1/accounts/index.ts @@ -1,7 +1,6 @@ -import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; -import { response } from "@/response"; +import { apiRoute, applyConfig, auth } from "@/api"; import { tempmailDomains } from "@/tempmail"; -import { zValidator } from "@hono/zod-validator"; +import { createRoute } from "@hono/zod-openapi"; import { and, eq, isNull } from "drizzle-orm"; import ISO6391 from "iso-639-1"; import { z } from "zod"; @@ -39,222 +38,303 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - auth(meta.auth, meta.permissions, meta.challenge), - jsonOrForm(), - zValidator("json", schemas.json, handleZodError), - async (context) => { - const form = context.req.valid("json"); - const { username, email, password, agreement, locale } = - context.req.valid("json"); - - if (!config.signups.registration) { - return context.json( - { - error: "Registration is disabled", - }, - 422, - ); - } - - const errors: { - details: Record< - string, - { - error: - | "ERR_BLANK" - | "ERR_INVALID" - | "ERR_TOO_LONG" - | "ERR_TOO_SHORT" - | "ERR_BLOCKED" - | "ERR_TAKEN" - | "ERR_RESERVED" - | "ERR_ACCEPTED" - | "ERR_INCLUSION"; - description: string; - }[] - >; - } = { - details: { - password: [], - username: [], - email: [], - agreement: [], - locale: [], - reason: [], +const route = createRoute({ + method: "post", + path: "/api/v1/accounts", + summary: "Create account", + description: "Register a new account", + middleware: [auth(meta.auth, meta.permissions, meta.challenge)], + request: { + body: { + content: { + "application/json": { + schema: schemas.json, }, - }; + "multipart/form-data": { + schema: schemas.json, + }, + "application/x-www-form-urlencoded": { + schema: schemas.json, + }, + }, + }, + }, + responses: { + 200: { + description: "Account created", + }, + 422: { + description: "Validation failed", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + details: z.object({ + username: z.array( + z.object({ + error: z.enum([ + "ERR_BLANK", + "ERR_INVALID", + "ERR_TOO_LONG", + "ERR_TOO_SHORT", + "ERR_BLOCKED", + "ERR_TAKEN", + "ERR_RESERVED", + "ERR_ACCEPTED", + "ERR_INCLUSION", + ]), + description: z.string(), + }), + ), + email: z.array( + z.object({ + error: z.enum([ + "ERR_BLANK", + "ERR_INVALID", + "ERR_BLOCKED", + "ERR_TAKEN", + ]), + description: z.string(), + }), + ), + password: z.array( + z.object({ + error: z.enum([ + "ERR_BLANK", + "ERR_INVALID", + "ERR_TOO_LONG", + "ERR_TOO_SHORT", + ]), + description: z.string(), + }), + ), + agreement: z.array( + z.object({ + error: z.enum(["ERR_ACCEPTED"]), + description: z.string(), + }), + ), + locale: z.array( + z.object({ + error: z.enum(["ERR_BLANK", "ERR_INVALID"]), + description: z.string(), + }), + ), + reason: z.array( + z.object({ + error: z.enum(["ERR_BLANK"]), + description: z.string(), + }), + ), + }), + }), + }, + }, + }, + }, +}); - // Check if fields are blank - for (const value of [ - "username", - "email", - "password", - "agreement", - "locale", - "reason", - ]) { - // @ts-expect-error We don't care about the type here - if (!form[value]) { - errors.details[value].push({ - error: "ERR_BLANK", - description: `can't be blank`, - }); - } - } +export default apiRoute((app) => + app.openapi(route, async (context) => { + const form = context.req.valid("json"); + const { username, email, password, agreement, locale } = + context.req.valid("json"); - // Check if username is valid - if (!username?.match(/^[a-z0-9_]+$/)) { - errors.details.username.push({ - error: "ERR_INVALID", - description: - "must only contain lowercase letters, numbers, and underscores", - }); - } + if (!config.signups.registration) { + return context.json( + { + error: "Registration is disabled", + }, + 422, + ); + } - // Check if username doesnt match filters - if ( - config.filters.username.some((filter) => - username?.match(filter), - ) - ) { - errors.details.username.push({ - error: "ERR_INVALID", - description: "contains blocked words", - }); - } + const errors: { + details: Record< + string, + { + error: + | "ERR_BLANK" + | "ERR_INVALID" + | "ERR_TOO_LONG" + | "ERR_TOO_SHORT" + | "ERR_BLOCKED" + | "ERR_TAKEN" + | "ERR_RESERVED" + | "ERR_ACCEPTED" + | "ERR_INCLUSION"; + description: string; + }[] + >; + } = { + details: { + password: [], + username: [], + email: [], + agreement: [], + locale: [], + reason: [], + }, + }; - // Check if username is too long - if ((username?.length ?? 0) > config.validation.max_username_size) { - errors.details.username.push({ - error: "ERR_TOO_LONG", - description: `is too long (maximum is ${config.validation.max_username_size} characters)`, - }); - } - - // Check if username is too short - if ((username?.length ?? 0) < 3) { - errors.details.username.push({ - error: "ERR_TOO_SHORT", - description: "is too short (minimum is 3 characters)", - }); - } - - // Check if username is reserved - if (config.validation.username_blacklist.includes(username ?? "")) { - errors.details.username.push({ - error: "ERR_RESERVED", - description: "is reserved", - }); - } - - // Check if username is taken - if ( - await User.fromSql( - and(eq(Users.username, username)), - isNull(Users.instanceId), - ) - ) { - errors.details.username.push({ - error: "ERR_TAKEN", - description: "is already taken", - }); - } - - // Check if email is valid - if ( - !email?.match( - /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, - ) - ) { - errors.details.email.push({ - error: "ERR_INVALID", - description: "must be a valid email address", - }); - } - - // Check if email is blocked - if ( - config.validation.email_blacklist.includes(email) || - (config.validation.blacklist_tempmail && - tempmailDomains.domains.includes( - (email ?? "").split("@")[1], - )) - ) { - errors.details.email.push({ - error: "ERR_BLOCKED", - description: "is from a blocked email provider", - }); - } - - // Check if email is taken - if (await User.fromSql(eq(Users.email, email))) { - errors.details.email.push({ - error: "ERR_TAKEN", - description: "is already taken", - }); - } - - // Check if agreement is accepted - if (!agreement) { - errors.details.agreement.push({ - error: "ERR_ACCEPTED", - description: "must be accepted", - }); - } - - if (!locale) { - errors.details.locale.push({ + // Check if fields are blank + for (const value of [ + "username", + "email", + "password", + "agreement", + "locale", + "reason", + ]) { + // @ts-expect-error We don't care about the type here + if (!form[value]) { + errors.details[value].push({ error: "ERR_BLANK", description: `can't be blank`, }); } + } - if (!ISO6391.validate(locale ?? "")) { - errors.details.locale.push({ - error: "ERR_INVALID", - description: "must be a valid ISO 639-1 code", - }); - } - - // If any errors are present, return them - if ( - Object.values(errors.details).some((value) => value.length > 0) - ) { - // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted" - - const errorsText = Object.entries(errors.details) - .filter(([_, errors]) => errors.length > 0) - .map( - ([name, errors]) => - `${name} ${errors - .map((error) => error.description) - .join(", ")}`, - ) - .join(", "); - return context.json( - { - error: `Validation failed: ${errorsText}`, - details: Object.fromEntries( - Object.entries(errors.details).filter( - ([_, errors]) => errors.length > 0, - ), - ), - }, - 422, - ); - } - - await User.fromDataLocal({ - username: username ?? "", - password: password ?? "", - email: email ?? "", + // Check if username is valid + if (!username?.match(/^[a-z0-9_]+$/)) { + errors.details.username.push({ + error: "ERR_INVALID", + description: + "must only contain lowercase letters, numbers, and underscores", }); + } - return response(null, 200); - }, - ), + // Check if username doesnt match filters + if (config.filters.username.some((filter) => username?.match(filter))) { + errors.details.username.push({ + error: "ERR_INVALID", + description: "contains blocked words", + }); + } + + // Check if username is too long + if ((username?.length ?? 0) > config.validation.max_username_size) { + errors.details.username.push({ + error: "ERR_TOO_LONG", + description: `is too long (maximum is ${config.validation.max_username_size} characters)`, + }); + } + + // Check if username is too short + if ((username?.length ?? 0) < 3) { + errors.details.username.push({ + error: "ERR_TOO_SHORT", + description: "is too short (minimum is 3 characters)", + }); + } + + // Check if username is reserved + if (config.validation.username_blacklist.includes(username ?? "")) { + errors.details.username.push({ + error: "ERR_RESERVED", + description: "is reserved", + }); + } + + // Check if username is taken + if ( + await User.fromSql( + and(eq(Users.username, username)), + isNull(Users.instanceId), + ) + ) { + errors.details.username.push({ + error: "ERR_TAKEN", + description: "is already taken", + }); + } + + // Check if email is valid + if ( + !email?.match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ) + ) { + errors.details.email.push({ + error: "ERR_INVALID", + description: "must be a valid email address", + }); + } + + // Check if email is blocked + if ( + config.validation.email_blacklist.includes(email) || + (config.validation.blacklist_tempmail && + tempmailDomains.domains.includes((email ?? "").split("@")[1])) + ) { + errors.details.email.push({ + error: "ERR_BLOCKED", + description: "is from a blocked email provider", + }); + } + + // Check if email is taken + if (await User.fromSql(eq(Users.email, email))) { + errors.details.email.push({ + error: "ERR_TAKEN", + description: "is already taken", + }); + } + + // Check if agreement is accepted + if (!agreement) { + errors.details.agreement.push({ + error: "ERR_ACCEPTED", + description: "must be accepted", + }); + } + + if (!locale) { + errors.details.locale.push({ + error: "ERR_BLANK", + description: `can't be blank`, + }); + } + + if (!ISO6391.validate(locale ?? "")) { + errors.details.locale.push({ + error: "ERR_INVALID", + description: "must be a valid ISO 639-1 code", + }); + } + + // If any errors are present, return them + if (Object.values(errors.details).some((value) => value.length > 0)) { + // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted" + + const errorsText = Object.entries(errors.details) + .filter(([_, errors]) => errors.length > 0) + .map( + ([name, errors]) => + `${name} ${errors + .map((error) => error.description) + .join(", ")}`, + ) + .join(", "); + return context.json( + { + error: `Validation failed: ${errorsText}`, + details: Object.fromEntries( + Object.entries(errors.details).filter( + ([_, errors]) => errors.length > 0, + ), + ), + }, + 422, + ); + } + + await User.fromDataLocal({ + username: username ?? "", + password: password ?? "", + email: email ?? "", + }); + + return context.newResponse(null, 200); + }), ); diff --git a/api/api/v1/accounts/lookup/index.ts b/api/api/v1/accounts/lookup/index.ts index 08d1051b..7d96d8f0 100644 --- a/api/api/v1/accounts/lookup/index.ts +++ b/api/api/v1/accounts/lookup/index.ts @@ -1,5 +1,5 @@ -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 { eq } from "drizzle-orm"; import { anyOf, @@ -15,6 +15,7 @@ import { import { z } from "zod"; import { RolePermissions, Users } from "~/drizzle/schema"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -38,72 +39,91 @@ 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 { acct } = context.req.valid("query"); - const { user } = context.get("auth"); - - if (!acct) { - return context.json({ error: "Invalid acct parameter" }, 400); - } - - // Check if acct is matching format username@domain.com or @username@domain.com - const accountMatches = acct?.trim().match( - createRegExp( - maybe("@"), - oneOrMore( - anyOf(letter.lowercase, digit, charIn("-")), - ).groupedAs("username"), - exactly("@"), - oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs( - "domain", - ), - - [global], - ), - ); - - if (accountMatches) { - // Remove leading @ if it exists - if (accountMatches[0].startsWith("@")) { - accountMatches[0] = accountMatches[0].slice(1); - } - - const [username, domain] = accountMatches[0].split("@"); - - const manager = await (user ?? User).getFederationRequester(); - - const uri = await User.webFinger(manager, username, domain); - - const foundAccount = await User.resolve(uri); - - if (foundAccount) { - return context.json(foundAccount.toApi()); - } - - return context.json({ error: "Account not found" }, 404); - } - - let username = acct; - if (username.startsWith("@")) { - username = username.slice(1); - } - - const account = await User.fromSql(eq(Users.username, username)); - - if (account) { - return context.json(account.toApi()); - } - - return context.json( - { error: `Account with username ${username} not found` }, - 404, - ); +const route = createRoute({ + method: "get", + path: "/api/v1/accounts/lookup", + summary: "Lookup account", + description: "Lookup an account by acct", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Account", + content: { + "application/json": { + schema: User.schema, + }, + }, }, - ), + 404: { + description: "Not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { acct } = context.req.valid("query"); + const { user } = context.get("auth"); + + // Check if acct is matching format username@domain.com or @username@domain.com + const accountMatches = acct?.trim().match( + createRegExp( + maybe("@"), + oneOrMore( + anyOf(letter.lowercase, digit, charIn("-")), + ).groupedAs("username"), + exactly("@"), + oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs( + "domain", + ), + + [global], + ), + ); + + if (accountMatches) { + // Remove leading @ if it exists + if (accountMatches[0].startsWith("@")) { + accountMatches[0] = accountMatches[0].slice(1); + } + + const [username, domain] = accountMatches[0].split("@"); + + const manager = await (user ?? User).getFederationRequester(); + + const uri = await User.webFinger(manager, username, domain); + + const foundAccount = await User.resolve(uri); + + if (foundAccount) { + return context.json(foundAccount.toApi(), 200); + } + + return context.json({ error: "Account not found" }, 404); + } + + let username = acct; + if (username.startsWith("@")) { + username = username.slice(1); + } + + const account = await User.fromSql(eq(Users.username, username)); + + if (account) { + return context.json(account.toApi(), 200); + } + + return context.json( + { error: `Account with username ${username} not found` }, + 404, + ); + }), ); diff --git a/api/api/v1/accounts/relationships/index.ts b/api/api/v1/accounts/relationships/index.ts index 0e39ab71..ecf2201c 100644 --- a/api/api/v1/accounts/relationships/index.ts +++ b/api/api/v1/accounts/relationships/index.ts @@ -1,8 +1,9 @@ -import { apiRoute, applyConfig, auth, handleZodError, qsQuery } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, qsQuery } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { z } from "zod"; import { RolePermissions } from "~/drizzle/schema"; import { Relationship } from "~/packages/database-interface/relationship"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -26,35 +27,59 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - qsQuery(), - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { user: self } = context.get("auth"); - const { id } = context.req.valid("query"); - - const ids = Array.isArray(id) ? id : [id]; - - if (!self) { - return context.json({ error: "Unauthorized" }, 401); - } - - const relationships = await Relationship.fromOwnerAndSubjects( - self, - ids, - ); - - relationships.sort( - (a, b) => - ids.indexOf(a.data.subjectId) - - ids.indexOf(b.data.subjectId), - ); - - return context.json(relationships.map((r) => r.toApi())); +const route = createRoute({ + method: "get", + path: "/api/v1/accounts/relationships", + summary: "Get relationships", + description: "Get relationships by account ID", + middleware: [auth(meta.auth, meta.permissions), qsQuery()], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Relationships", + content: { + "application/json": { + schema: z.array(Relationship.schema), + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { user: self } = context.get("auth"); + const { id } = context.req.valid("query"); + + const ids = Array.isArray(id) ? id : [id]; + + if (!self) { + return context.json({ error: "Unauthorized" }, 401); + } + + const relationships = await Relationship.fromOwnerAndSubjects( + self, + ids, + ); + + relationships.sort( + (a, b) => + ids.indexOf(a.data.subjectId) - ids.indexOf(b.data.subjectId), + ); + + return context.json( + relationships.map((r) => r.toApi()), + 200, + ); + }), ); diff --git a/api/api/v1/accounts/search/index.ts b/api/api/v1/accounts/search/index.ts index a6f3d22e..4048af23 100644 --- a/api/api/v1/accounts/search/index.ts +++ b/api/api/v1/accounts/search/index.ts @@ -1,5 +1,5 @@ -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 { eq, ilike, not, or, sql } from "drizzle-orm"; import { anyOf, @@ -16,6 +16,7 @@ import stringComparison from "string-comparison"; import { z } from "zod"; import { RolePermissions, Users } from "~/drizzle/schema"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -67,63 +68,89 @@ 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 { q, limit, offset, resolve, following } = - context.req.valid("query"); - const { user: self } = context.get("auth"); - - if (!self && following) { - return context.json({ error: "Unauthorized" }, 401); - } - - const [username, host] = q.replace(/^@/, "").split("@"); - - const accounts: User[] = []; - - if (resolve && username && host) { - const manager = await (self ?? User).getFederationRequester(); - - const uri = await User.webFinger(manager, username, host); - - const resolvedUser = await User.resolve(uri); - - if (resolvedUser) { - accounts.push(resolvedUser); - } - } else { - accounts.push( - ...(await User.manyFromSql( - or( - ilike(Users.displayName, `%${q}%`), - ilike(Users.username, `%${q}%`), - following && self - ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)` - : undefined, - self ? not(eq(Users.id, self.id)) : undefined, - ), - undefined, - limit, - offset, - )), - ); - } - - const indexOfCorrectSort = stringComparison.jaccardIndex - .sortMatch( - q, - accounts.map((acct) => acct.getAcct()), - ) - .map((sort) => sort.index); - - const result = indexOfCorrectSort.map((index) => accounts[index]); - - return context.json(result.map((acct) => acct.toApi())); +export const route = createRoute({ + method: "get", + path: "/api/v1/accounts/search", + summary: "Search accounts", + description: "Search for accounts", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Accounts", + 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 { q, limit, offset, resolve, following } = + context.req.valid("query"); + const { user: self } = context.get("auth"); + + if (!self && following) { + return context.json({ error: "Unauthorized" }, 401); + } + + const [username, host] = q.replace(/^@/, "").split("@"); + + const accounts: User[] = []; + + if (resolve && username && host) { + const manager = await (self ?? User).getFederationRequester(); + + const uri = await User.webFinger(manager, username, host); + + const resolvedUser = await User.resolve(uri); + + if (resolvedUser) { + accounts.push(resolvedUser); + } + } else { + accounts.push( + ...(await User.manyFromSql( + or( + ilike(Users.displayName, `%${q}%`), + ilike(Users.username, `%${q}%`), + following && self + ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)` + : undefined, + self ? not(eq(Users.id, self.id)) : undefined, + ), + undefined, + limit, + offset, + )), + ); + } + + const indexOfCorrectSort = stringComparison.jaccardIndex + .sortMatch( + q, + accounts.map((acct) => acct.getAcct()), + ) + .map((sort) => sort.index); + + const result = indexOfCorrectSort.map((index) => accounts[index]); + + return context.json( + result.map((acct) => acct.toApi()), + 200, + ); + }), ); diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index bcb1774f..666aff82 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -1,6 +1,6 @@ -import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; +import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; import { sanitizedHtmlStrip } from "@/sanitization"; -import { zValidator } from "@hono/zod-validator"; +import { createRoute } from "@hono/zod-openapi"; import { and, eq, isNull } from "drizzle-orm"; import ISO6391 from "iso-639-1"; import { z } from "zod"; @@ -12,6 +12,7 @@ import { config } from "~/packages/config-manager/index"; import { Attachment } from "~/packages/database-interface/attachment"; import { Emoji } from "~/packages/database-interface/emoji"; import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["PATCH"], @@ -125,222 +126,264 @@ export const schemas = { }), }; +const route = createRoute({ + method: "patch", + path: "/api/v1/accounts/update_credentials", + summary: "Update credentials", + description: "Update user credentials", + middleware: [auth(meta.auth, meta.permissions), jsonOrForm()], + request: { + body: { + content: { + "application/json": { + schema: schemas.json, + }, + }, + }, + }, + responses: { + 200: { + description: "Updated user", + content: { + "application/json": { + schema: User.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 422: { + description: "Validation error", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 500: { + description: "Couldn't edit user", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + 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"); - const { - display_name, - username, - note, - avatar, - header, - locked, - bot, - discoverable, - source, - fields_attributes, - } = context.req.valid("json"); + app.openapi(route, async (context) => { + const { user } = context.get("auth"); + const { + display_name, + username, + note, + avatar, + header, + locked, + bot, + discoverable, + source, + fields_attributes, + } = context.req.valid("json"); - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } - const self = user.data; + const self = user.data; - const sanitizedDisplayName = await sanitizedHtmlStrip( - display_name ?? "", + const sanitizedDisplayName = await sanitizedHtmlStrip( + display_name ?? "", + ); + + const mediaManager = new MediaManager(config); + + if (display_name) { + self.displayName = sanitizedDisplayName; + } + + if (note && self.source) { + self.source.note = note; + self.note = await contentToHtml({ + "text/markdown": { + content: note, + remote: false, + }, + }); + } + + if (source?.privacy) { + self.source.privacy = source.privacy; + } + + if (source?.sensitive) { + self.source.sensitive = source.sensitive; + } + + if (source?.language) { + self.source.language = source.language; + } + + if (username) { + // Check if username is already taken + const existingUser = await User.fromSql( + and(isNull(Users.instanceId), eq(Users.username, username)), ); - const mediaManager = new MediaManager(config); - - if (display_name) { - self.displayName = sanitizedDisplayName; + if (existingUser) { + return context.json( + { error: "Username is already taken" }, + 422, + ); } - if (note && self.source) { - self.source.note = note; - self.note = await contentToHtml({ - "text/markdown": { - content: note, - remote: false, + self.username = username; + } + + if (avatar) { + const { path } = await mediaManager.addFile(avatar); + + self.avatar = Attachment.getUrl(path); + } + + if (header) { + const { path } = await mediaManager.addFile(header); + + self.header = Attachment.getUrl(path); + } + + if (locked) { + self.isLocked = locked; + } + + if (bot) { + self.isBot = bot; + } + + if (discoverable) { + self.isDiscoverable = discoverable; + } + + const fieldEmojis: Emoji[] = []; + + if (fields_attributes) { + self.fields = []; + self.source.fields = []; + for (const field of fields_attributes) { + // Can be Markdown or plaintext, also has emojis + const parsedName = await contentToHtml( + { + "text/markdown": { + content: field.name, + remote: false, + }, }, - }); - } - - if (source?.privacy) { - self.source.privacy = source.privacy; - } - - if (source?.sensitive) { - self.source.sensitive = source.sensitive; - } - - if (source?.language) { - self.source.language = source.language; - } - - if (username) { - // Check if username is already taken - const existingUser = await User.fromSql( - and(isNull(Users.instanceId), eq(Users.username, username)), + undefined, + true, ); - if (existingUser) { - return context.json( - { error: "Username is already taken" }, - 400, - ); - } - - self.username = username; - } - - if (avatar) { - const { path } = await mediaManager.addFile(avatar); - - self.avatar = Attachment.getUrl(path); - } - - if (header) { - const { path } = await mediaManager.addFile(header); - - self.header = Attachment.getUrl(path); - } - - if (locked) { - self.isLocked = locked; - } - - if (bot) { - self.isBot = bot; - } - - if (discoverable) { - self.isDiscoverable = discoverable; - } - - const fieldEmojis: Emoji[] = []; - - if (fields_attributes) { - self.fields = []; - self.source.fields = []; - for (const field of fields_attributes) { - // Can be Markdown or plaintext, also has emojis - const parsedName = await contentToHtml( - { - "text/markdown": { - content: field.name, - remote: false, - }, + const parsedValue = await contentToHtml( + { + "text/markdown": { + content: field.value, + remote: false, }, - undefined, - true, - ); + }, + undefined, + true, + ); - const parsedValue = await contentToHtml( - { - "text/markdown": { - content: field.value, - remote: false, - }, + // Parse emojis + const nameEmojis = await Emoji.parseFromText(parsedName); + const valueEmojis = await Emoji.parseFromText(parsedValue); + + fieldEmojis.push(...nameEmojis, ...valueEmojis); + + // Replace fields + self.fields.push({ + key: { + "text/html": { + content: parsedName, + remote: false, }, - undefined, - true, - ); - - // Parse emojis - const nameEmojis = await Emoji.parseFromText(parsedName); - const valueEmojis = await Emoji.parseFromText(parsedValue); - - fieldEmojis.push(...nameEmojis, ...valueEmojis); - - // Replace fields - self.fields.push({ - key: { - "text/html": { - content: parsedName, - remote: false, - }, + }, + value: { + "text/html": { + content: parsedValue, + remote: false, }, - value: { - "text/html": { - content: parsedValue, - remote: false, - }, - }, - }); + }, + }); - self.source.fields.push({ - name: field.name, - value: field.value, - }); - } + self.source.fields.push({ + name: field.name, + value: field.value, + }); } + } - // Parse emojis - const displaynameEmojis = - await Emoji.parseFromText(sanitizedDisplayName); - const noteEmojis = await Emoji.parseFromText(self.note); + // Parse emojis + const displaynameEmojis = + await Emoji.parseFromText(sanitizedDisplayName); + const noteEmojis = await Emoji.parseFromText(self.note); - self.emojis = [ - ...displaynameEmojis, - ...noteEmojis, - ...fieldEmojis, - ].map((e) => e.data); + self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis].map( + (e) => e.data, + ); - // Deduplicate emojis - self.emojis = self.emojis.filter( - (emoji, index, self) => - self.findIndex((e) => e.id === emoji.id) === index, - ); + // Deduplicate emojis + self.emojis = self.emojis.filter( + (emoji, index, self) => + self.findIndex((e) => e.id === emoji.id) === index, + ); - // Connect emojis, if any - // Do it before updating user, so that federation takes that into account - for (const emoji of self.emojis) { - await db - .delete(EmojiToUser) - .where( - and( - eq(EmojiToUser.emojiId, emoji.id), - eq(EmojiToUser.userId, self.id), - ), - ) - .execute(); + // Connect emojis, if any + // Do it before updating user, so that federation takes that into account + for (const emoji of self.emojis) { + await db + .delete(EmojiToUser) + .where( + and( + eq(EmojiToUser.emojiId, emoji.id), + eq(EmojiToUser.userId, self.id), + ), + ) + .execute(); - await db - .insert(EmojiToUser) - .values({ - emojiId: emoji.id, - userId: self.id, - }) - .execute(); - } + await db + .insert(EmojiToUser) + .values({ + emojiId: emoji.id, + userId: self.id, + }) + .execute(); + } - await user.update({ - displayName: self.displayName, - username: self.username, - note: self.note, - avatar: self.avatar, - header: self.header, - fields: self.fields, - isLocked: self.isLocked, - isBot: self.isBot, - isDiscoverable: self.isDiscoverable, - source: self.source || undefined, - }); + await user.update({ + displayName: self.displayName, + username: self.username, + note: self.note, + avatar: self.avatar, + header: self.header, + fields: self.fields, + isLocked: self.isLocked, + isBot: self.isBot, + isDiscoverable: self.isDiscoverable, + source: self.source || undefined, + }); - const output = await User.fromId(self.id); - if (!output) { - return context.json({ error: "Couldn't edit user" }, 500); - } + const output = await User.fromId(self.id); + if (!output) { + return context.json({ error: "Couldn't edit user" }, 500); + } - return context.json(output.toApi()); - }, - ), + return context.json(output.toApi(), 200); + }), ); diff --git a/api/api/v1/accounts/verify_credentials/index.ts b/api/api/v1/accounts/verify_credentials/index.ts index a3c67172..e104e6db 100644 --- a/api/api/v1/accounts/verify_credentials/index.ts +++ b/api/api/v1/accounts/verify_credentials/index.ts @@ -1,4 +1,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; +import { User } from "~/packages/database-interface/user"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -13,20 +16,41 @@ export const meta = applyConfig({ }, }); -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - auth(meta.auth, meta.permissions), - (context) => { - // TODO: Add checks for disabled/unverified accounts - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - return context.json(user.toApi(true)); +const route = createRoute({ + method: "get", + path: "/api/v1/accounts/verify_credentials", + summary: "Verify credentials", + description: "Get your own account information", + middleware: [auth(meta.auth)], + responses: { + 200: { + description: "Account", + content: { + "application/json": { + schema: User.schema, + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, (context) => { + // TODO: Add checks for disabled/unverified accounts + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + return context.json(user.toApi(true), 200); + }), ); diff --git a/api/api/v1/apps/index.ts b/api/api/v1/apps/index.ts index 769366be..ab4d4ca3 100644 --- a/api/api/v1/apps/index.ts +++ b/api/api/v1/apps/index.ts @@ -1,6 +1,6 @@ -import { apiRoute, applyConfig, handleZodError, jsonOrForm } from "@/api"; +import { apiRoute, applyConfig, jsonOrForm } from "@/api"; import { randomString } from "@/math"; -import { zValidator } from "@hono/zod-validator"; +import { createRoute } from "@hono/zod-openapi"; import { z } from "zod"; import { db } from "~/drizzle/db"; import { Applications, RolePermissions } from "~/drizzle/schema"; @@ -42,31 +42,62 @@ export const schemas = { }), }; +const route = createRoute({ + method: "post", + path: "/api/v1/apps", + summary: "Create app", + description: "Create an OAuth2 app", + middleware: [jsonOrForm()], + request: { + body: { + content: { + "application/json": { + schema: schemas.json, + }, + }, + }, + }, + responses: { + 200: { + description: "App", + content: { + "application/json": { + schema: z.object({ + id: z.string().uuid(), + name: z.string(), + website: z.string().nullable(), + client_id: z.string(), + client_secret: z.string(), + redirect_uri: z.string(), + vapid_link: z.string().nullable(), + }), + }, + }, + }, + }, +}); + export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - zValidator("json", schemas.json, handleZodError), - async (context) => { - const { client_name, redirect_uris, scopes, website } = - context.req.valid("json"); + app.openapi(route, async (context) => { + const { client_name, redirect_uris, scopes, website } = + context.req.valid("json"); - const app = ( - await db - .insert(Applications) - .values({ - name: client_name || "", - redirectUri: decodeURIComponent(redirect_uris) || "", - scopes: scopes || "read", - website: website || null, - clientId: randomString(32, "base64url"), - secret: randomString(64, "base64url"), - }) - .returning() - )[0]; + const app = ( + await db + .insert(Applications) + .values({ + name: client_name || "", + redirectUri: decodeURIComponent(redirect_uris) || "", + scopes: scopes || "read", + website: website || null, + clientId: randomString(32, "base64url"), + secret: randomString(64, "base64url"), + }) + .returning() + )[0]; - return context.json({ + return context.json( + { id: app.id, name: app.name, website: app.website, @@ -74,7 +105,8 @@ export default apiRoute((app) => client_secret: app.secret, redirect_uri: app.redirectUri, vapid_link: app.vapidKey, - }); - }, - ), + }, + 200, + ); + }), ); diff --git a/api/api/v1/apps/verify_credentials/index.ts b/api/api/v1/apps/verify_credentials/index.ts index 930d3186..3772127d 100644 --- a/api/api/v1/apps/verify_credentials/index.ts +++ b/api/api/v1/apps/verify_credentials/index.ts @@ -1,6 +1,8 @@ import { apiRoute, applyConfig, auth } from "@/api"; +import { createRoute, z } from "@hono/zod-openapi"; import { getFromToken } from "~/classes/functions/application"; import { RolePermissions } from "~/drizzle/schema"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -17,34 +19,64 @@ export const meta = applyConfig({ }, }); +const route = createRoute({ + method: "get", + path: "/api/v1/apps/verify_credentials", + summary: "Verify credentials", + description: "Get your own application information", + middleware: [auth(meta.auth, meta.permissions)], + responses: { + 200: { + description: "Application", + content: { + "application/json": { + schema: z.object({ + name: z.string(), + website: z.string().nullable(), + vapid_key: z.string().nullable(), + redirect_uris: z.string(), + scopes: z.string(), + }), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - auth(meta.auth, meta.permissions), - async (context) => { - const { user, token } = context.get("auth"); + app.openapi(route, async (context) => { + const { user, token } = context.get("auth"); - if (!token) { - return context.json({ error: "Unauthorized" }, 401); - } - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } + if (!token) { + return context.json({ error: "Unauthorized" }, 401); + } + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } - const application = await getFromToken(token); + const application = await getFromToken(token); - if (!application) { - return context.json({ error: "Unauthorized" }, 401); - } + if (!application) { + return context.json({ error: "Unauthorized" }, 401); + } - return context.json({ + return context.json( + { name: application.name, website: application.website, vapid_key: application.vapidKey, redirect_uris: application.redirectUri, scopes: application.scopes, - }); - }, - ), + }, + 200, + ); + }), ); diff --git a/api/api/v1/blocks/index.ts b/api/api/v1/blocks/index.ts index 930055c1..8534362e 100644 --- a/api/api/v1/blocks/index.ts +++ b/api/api/v1/blocks/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,40 +32,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: blocks, 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"."blocking" = true)`, - ), - limit, - context.req.url, - ); - - return context.json( - blocks.map((u) => u.toApi()), - 200, - { - Link: link, +const route = createRoute({ + method: "get", + path: "/api/v1/blocks", + summary: "Get blocks", + description: "Get users you have blocked", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Blocks", + 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: blocks, 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"."blocking" = true)`, + ), + limit, + context.req.url, + ); + + return context.json( + blocks.map((u) => u.toApi()), + 200, + { + Link: link, + }, + ); + }), ); diff --git a/api/api/v1/challenges/index.ts b/api/api/v1/challenges/index.ts index b7f8f1b4..8ce46615 100644 --- a/api/api/v1/challenges/index.ts +++ b/api/api/v1/challenges/index.ts @@ -1,6 +1,8 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { generateChallenge } from "@/challenges"; +import { createRoute, z } from "@hono/zod-openapi"; import { config } from "~/packages/config-manager"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -17,25 +19,56 @@ export const meta = applyConfig({ }, }); +const route = createRoute({ + method: "post", + path: "/api/v1/challenges", + summary: "Generate a challenge", + description: "Generate a challenge to solve", + middleware: [auth(meta.auth, meta.permissions)], + responses: { + 200: { + description: "Challenge", + content: { + "application/json": { + schema: z.object({ + id: z.string(), + algorithm: z.enum(["SHA-1", "SHA-256", "SHA-512"]), + challenge: z.string(), + maxnumber: z.number().optional(), + salt: z.string(), + signature: z.string(), + }), + }, + }, + }, + 400: { + description: "Challenges are disabled", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - auth(meta.auth, meta.permissions), - async (context) => { - if (!config.validation.challenges.enabled) { - return context.json( - { error: "Challenges are disabled in config" }, - 400, - ); - } + app.openapi(route, async (context) => { + if (!config.validation.challenges.enabled) { + return context.json( + { error: "Challenges are disabled in config" }, + 400, + ); + } - const result = await generateChallenge(); + const result = await generateChallenge(); - return context.json({ + return context.json( + { id: result.id, ...result.challenge, - }); - }, - ), + }, + 200, + ); + }), ); diff --git a/api/api/v1/custom_emojis/index.ts b/api/api/v1/custom_emojis/index.ts index 1359e262..9261db01 100644 --- a/api/api/v1/custom_emojis/index.ts +++ b/api/api/v1/custom_emojis/index.ts @@ -1,4 +1,5 @@ import { apiRoute, applyConfig, auth } from "@/api"; +import { createRoute, z } from "@hono/zod-openapi"; import { and, eq, isNull, or } from "drizzle-orm"; import { Emojis, RolePermissions } from "~/drizzle/schema"; import { Emoji } from "~/packages/database-interface/emoji"; @@ -18,25 +19,41 @@ export const meta = applyConfig({ }, }); -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - auth(meta.auth, meta.permissions), - async (context) => { - const { user } = context.get("auth"); - - const emojis = await Emoji.manyFromSql( - and( - isNull(Emojis.instanceId), - or( - isNull(Emojis.ownerId), - user ? eq(Emojis.ownerId, user.id) : undefined, - ), - ), - ); - - return context.json(emojis.map((emoji) => emoji.toApi())); +const route = createRoute({ + method: "get", + path: "/api/v1/custom_emojis", + summary: "Get custom emojis", + description: "Get custom emojis", + middleware: [auth(meta.auth, meta.permissions)], + responses: { + 200: { + description: "Emojis", + content: { + "application/json": { + schema: z.array(Emoji.schema), + }, + }, }, - ), + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { user } = context.get("auth"); + + const emojis = await Emoji.manyFromSql( + and( + isNull(Emojis.instanceId), + or( + isNull(Emojis.ownerId), + user ? eq(Emojis.ownerId, user.id) : undefined, + ), + ), + ); + + return context.json( + emojis.map((emoji) => emoji.toApi()), + 200, + ); + }), ); diff --git a/api/api/v1/emojis/index.ts b/api/api/v1/emojis/index.ts index 13f6bfe9..388c40b4 100644 --- a/api/api/v1/emojis/index.ts +++ b/api/api/v1/emojis/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 { and, eq, isNull, or } from "drizzle-orm"; import { z } from "zod"; import { MediaManager } from "~/classes/media/media-manager"; @@ -15,6 +8,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: ["POST"], @@ -59,88 +53,128 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - zValidator("json", schemas.json, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { shortcode, element, alt, global, category } = - context.req.valid("json"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - if (!user.hasPermission(RolePermissions.ManageEmojis) && global) { - return context.json( - { - error: `Only users with the '${RolePermissions.ManageEmojis}' permission can upload global emojis`, - }, - 401, - ); - } - - // Check if emoji already exists - const existing = await Emoji.fromSql( - and( - eq(Emojis.shortcode, shortcode), - isNull(Emojis.instanceId), - or(eq(Emojis.ownerId, user.id), isNull(Emojis.ownerId)), - ), - ); - - if (existing) { - return context.json( - { - error: `An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`, - }, - 422, - ); - } - - let url = ""; - - // Check of emoji is an image - let contentType = - element instanceof File - ? element.type - : await mimeLookup(element); - - if (!contentType.startsWith("image/")) { - return context.json( - { - error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, - }, - 422, - ); - } - - if (element instanceof File) { - const mediaManager = new MediaManager(config); - - const uploaded = await mediaManager.addFile(element); - - url = uploaded.path; - contentType = uploaded.uploadedFile.type; - } else { - url = element; - } - - const emoji = await Emoji.insert({ - shortcode, - url: Attachment.getUrl(url), - visibleInPicker: true, - ownerId: global ? null : user.id, - category, - contentType, - alt, - }); - - return context.json(emoji.toApi()); +const route = createRoute({ + method: "post", + path: "/api/v1/emojis", + summary: "Upload emoji", + description: "Upload an emoji", + middleware: [auth(meta.auth, meta.permissions), jsonOrForm()], + request: { + body: { + content: { + "application/json": { + schema: schemas.json, + }, + "multipart/form-data": { + schema: schemas.json, + }, + "application/x-www-form-urlencoded": { + schema: schemas.json, + }, + }, }, - ), + }, + responses: { + 200: { + description: "uploaded emoji", + content: { + "application/json": { + schema: Emoji.schema, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 422: { + description: "Invalid data", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { shortcode, element, alt, global, category } = + context.req.valid("json"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + if (!user.hasPermission(RolePermissions.ManageEmojis) && global) { + return context.json( + { + error: `Only users with the '${RolePermissions.ManageEmojis}' permission can upload global emojis`, + }, + 401, + ); + } + + // Check if emoji already exists + const existing = await Emoji.fromSql( + and( + eq(Emojis.shortcode, shortcode), + isNull(Emojis.instanceId), + or(eq(Emojis.ownerId, user.id), isNull(Emojis.ownerId)), + ), + ); + + if (existing) { + return context.json( + { + error: `An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`, + }, + 422, + ); + } + + let url = ""; + + // Check of emoji is an image + let contentType = + element instanceof File ? element.type : await mimeLookup(element); + + if (!contentType.startsWith("image/")) { + return context.json( + { + error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, + }, + 422, + ); + } + + if (element instanceof File) { + const mediaManager = new MediaManager(config); + + const uploaded = await mediaManager.addFile(element); + + url = uploaded.path; + contentType = uploaded.uploadedFile.type; + } else { + url = element; + } + + const emoji = await Emoji.insert({ + shortcode, + url: Attachment.getUrl(url), + visibleInPicker: true, + ownerId: global ? null : user.id, + category, + contentType, + alt, + }); + + return context.json(emoji.toApi(), 200); + }), );