refactor(api): ♻️ Refactor more routes to use OpenAPI

This commit is contained in:
Jesse Wierzbinski 2024-08-27 20:14:10 +02:00
parent 5554038f44
commit 6ed1bd747f
No known key found for this signature in database
14 changed files with 1317 additions and 884 deletions

View file

@ -1,10 +1,11 @@
import { apiRoute, applyConfig, auth, handleZodError, qsQuery } from "@/api"; import { apiRoute, applyConfig, auth, qsQuery } from "@/api";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import { inArray } from "drizzle-orm"; import { inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { RolePermissions, Users } from "~/drizzle/schema"; import { RolePermissions, Users } from "~/drizzle/schema";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -28,70 +29,93 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/accounts/familiar_followers",
meta.route, summary: "Get familiar followers",
qsQuery(), description:
zValidator("query", schemas.query, handleZodError), "Obtain a list of all accounts that follow a given account, filtered for accounts you follow.",
auth(meta.auth, meta.permissions), middleware: [auth(meta.auth, meta.permissions), qsQuery()],
async (context) => { request: {
const { user: self } = context.get("auth"); query: schemas.query,
const { id: ids } = context.req.valid("query"); },
responses: {
if (!self) { 200: {
return context.json({ error: "Unauthorized" }, 401); description: "Familiar followers",
} content: {
"application/json": {
const idFollowerRelationships = schema: z.array(User.schema),
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),
),
}, },
); },
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,
);
}),
); );

View file

@ -1,9 +1,10 @@
import { apiRoute, applyConfig, auth, handleZodError } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { RolePermissions, Users } from "~/drizzle/schema"; import { RolePermissions, Users } from "~/drizzle/schema";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -27,24 +28,47 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/accounts/id",
meta.route, summary: "Get account by username",
zValidator("query", schemas.query, handleZodError), description: "Get an account by username",
auth(meta.auth, meta.permissions), middleware: [auth(meta.auth, meta.permissions)],
async (context) => { request: {
const { username } = context.req.valid("query"); query: schemas.query,
},
const user = await User.fromSql( responses: {
and(eq(Users.username, username), isNull(Users.instanceId)), 200: {
); description: "Account",
content: {
if (!user) { "application/json": {
return context.json({ error: "User not found" }, 404); schema: User.schema,
} },
},
return context.json(user.toApi());
}, },
), 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);
}),
); );

View file

@ -1,7 +1,6 @@
import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { response } from "@/response";
import { tempmailDomains } from "@/tempmail"; import { tempmailDomains } from "@/tempmail";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod"; import { z } from "zod";
@ -39,222 +38,303 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "post",
meta.allowedMethods, path: "/api/v1/accounts",
meta.route, summary: "Create account",
auth(meta.auth, meta.permissions, meta.challenge), description: "Register a new account",
jsonOrForm(), middleware: [auth(meta.auth, meta.permissions, meta.challenge)],
zValidator("json", schemas.json, handleZodError), request: {
async (context) => { body: {
const form = context.req.valid("json"); content: {
const { username, email, password, agreement, locale } = "application/json": {
context.req.valid("json"); schema: schemas.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: [],
}, },
}; "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 export default apiRoute((app) =>
for (const value of [ app.openapi(route, async (context) => {
"username", const form = context.req.valid("json");
"email", const { username, email, password, agreement, locale } =
"password", context.req.valid("json");
"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`,
});
}
}
// Check if username is valid if (!config.signups.registration) {
if (!username?.match(/^[a-z0-9_]+$/)) { return context.json(
errors.details.username.push({ {
error: "ERR_INVALID", error: "Registration is disabled",
description: },
"must only contain lowercase letters, numbers, and underscores", 422,
}); );
} }
// Check if username doesnt match filters const errors: {
if ( details: Record<
config.filters.username.some((filter) => string,
username?.match(filter), {
) error:
) { | "ERR_BLANK"
errors.details.username.push({ | "ERR_INVALID"
error: "ERR_INVALID", | "ERR_TOO_LONG"
description: "contains blocked words", | "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 // Check if fields are blank
if ((username?.length ?? 0) > config.validation.max_username_size) { for (const value of [
errors.details.username.push({ "username",
error: "ERR_TOO_LONG", "email",
description: `is too long (maximum is ${config.validation.max_username_size} characters)`, "password",
}); "agreement",
} "locale",
"reason",
// Check if username is too short ]) {
if ((username?.length ?? 0) < 3) { // @ts-expect-error We don't care about the type here
errors.details.username.push({ if (!form[value]) {
error: "ERR_TOO_SHORT", errors.details[value].push({
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", error: "ERR_BLANK",
description: `can't be blank`, description: `can't be blank`,
}); });
} }
}
if (!ISO6391.validate(locale ?? "")) { // Check if username is valid
errors.details.locale.push({ if (!username?.match(/^[a-z0-9_]+$/)) {
error: "ERR_INVALID", errors.details.username.push({
description: "must be a valid ISO 639-1 code", error: "ERR_INVALID",
}); description:
} "must only contain lowercase letters, numbers, and underscores",
// 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 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);
}),
); );

View file

@ -1,5 +1,5 @@
import { apiRoute, applyConfig, auth, handleZodError } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { import {
anyOf, anyOf,
@ -15,6 +15,7 @@ import {
import { z } from "zod"; import { z } from "zod";
import { RolePermissions, Users } from "~/drizzle/schema"; import { RolePermissions, Users } from "~/drizzle/schema";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -38,72 +39,91 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/accounts/lookup",
meta.route, summary: "Lookup account",
zValidator("query", schemas.query, handleZodError), description: "Lookup an account by acct",
auth(meta.auth, meta.permissions), middleware: [auth(meta.auth, meta.permissions)],
async (context) => { request: {
const { acct } = context.req.valid("query"); query: schemas.query,
const { user } = context.get("auth"); },
responses: {
if (!acct) { 200: {
return context.json({ error: "Invalid acct parameter" }, 400); description: "Account",
} content: {
"application/json": {
// Check if acct is matching format username@domain.com or @username@domain.com schema: User.schema,
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,
);
}, },
), 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,
);
}),
); );

View file

@ -1,8 +1,9 @@
import { apiRoute, applyConfig, auth, handleZodError, qsQuery } from "@/api"; import { apiRoute, applyConfig, auth, qsQuery } from "@/api";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import { z } from "zod"; import { z } from "zod";
import { RolePermissions } from "~/drizzle/schema"; import { RolePermissions } from "~/drizzle/schema";
import { Relationship } from "~/packages/database-interface/relationship"; import { Relationship } from "~/packages/database-interface/relationship";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -26,35 +27,59 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/accounts/relationships",
meta.route, summary: "Get relationships",
qsQuery(), description: "Get relationships by account ID",
zValidator("query", schemas.query, handleZodError), middleware: [auth(meta.auth, meta.permissions), qsQuery()],
auth(meta.auth, meta.permissions), request: {
async (context) => { query: schemas.query,
const { user: self } = context.get("auth"); },
const { id } = context.req.valid("query"); responses: {
200: {
const ids = Array.isArray(id) ? id : [id]; description: "Relationships",
content: {
if (!self) { "application/json": {
return context.json({ error: "Unauthorized" }, 401); schema: z.array(Relationship.schema),
} },
},
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()));
}, },
), 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,
);
}),
); );

View file

@ -1,5 +1,5 @@
import { apiRoute, applyConfig, auth, handleZodError } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import { eq, ilike, not, or, sql } from "drizzle-orm"; import { eq, ilike, not, or, sql } from "drizzle-orm";
import { import {
anyOf, anyOf,
@ -16,6 +16,7 @@ import stringComparison from "string-comparison";
import { z } from "zod"; import { z } from "zod";
import { RolePermissions, Users } from "~/drizzle/schema"; import { RolePermissions, Users } from "~/drizzle/schema";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -67,63 +68,89 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => export const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/accounts/search",
meta.route, summary: "Search accounts",
zValidator("query", schemas.query, handleZodError), description: "Search for accounts",
auth(meta.auth, meta.permissions), middleware: [auth(meta.auth, meta.permissions)],
async (context) => { request: {
const { q, limit, offset, resolve, following } = query: schemas.query,
context.req.valid("query"); },
const { user: self } = context.get("auth"); responses: {
200: {
if (!self && following) { description: "Accounts",
return context.json({ error: "Unauthorized" }, 401); content: {
} "application/json": {
schema: z.array(User.schema),
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()));
}, },
), 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,
);
}),
); );

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
import { sanitizedHtmlStrip } from "@/sanitization"; import { sanitizedHtmlStrip } from "@/sanitization";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod"; import { z } from "zod";
@ -12,6 +12,7 @@ import { config } from "~/packages/config-manager/index";
import { Attachment } from "~/packages/database-interface/attachment"; import { Attachment } from "~/packages/database-interface/attachment";
import { Emoji } from "~/packages/database-interface/emoji"; import { Emoji } from "~/packages/database-interface/emoji";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["PATCH"], 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) => export default apiRoute((app) =>
app.on( app.openapi(route, async (context) => {
meta.allowedMethods, const { user } = context.get("auth");
meta.route, const {
jsonOrForm(), display_name,
zValidator("json", schemas.json, handleZodError), username,
auth(meta.auth, meta.permissions), note,
async (context) => { avatar,
const { user } = context.get("auth"); header,
const { locked,
display_name, bot,
username, discoverable,
note, source,
avatar, fields_attributes,
header, } = context.req.valid("json");
locked,
bot,
discoverable,
source,
fields_attributes,
} = context.req.valid("json");
if (!user) { if (!user) {
return context.json({ error: "Unauthorized" }, 401); return context.json({ error: "Unauthorized" }, 401);
} }
const self = user.data; const self = user.data;
const sanitizedDisplayName = await sanitizedHtmlStrip( const sanitizedDisplayName = await sanitizedHtmlStrip(
display_name ?? "", 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 (existingUser) {
return context.json(
if (display_name) { { error: "Username is already taken" },
self.displayName = sanitizedDisplayName; 422,
);
} }
if (note && self.source) { self.username = username;
self.source.note = note; }
self.note = await contentToHtml({
"text/markdown": { if (avatar) {
content: note, const { path } = await mediaManager.addFile(avatar);
remote: false,
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,
},
}, },
}); undefined,
} true,
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)),
); );
if (existingUser) { const parsedValue = await contentToHtml(
return context.json( {
{ error: "Username is already taken" }, "text/markdown": {
400, content: field.value,
); 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,
},
}, },
undefined, },
true, undefined,
); true,
);
const parsedValue = await contentToHtml( // Parse emojis
{ const nameEmojis = await Emoji.parseFromText(parsedName);
"text/markdown": { const valueEmojis = await Emoji.parseFromText(parsedValue);
content: field.value,
remote: false, fieldEmojis.push(...nameEmojis, ...valueEmojis);
},
// Replace fields
self.fields.push({
key: {
"text/html": {
content: parsedName,
remote: false,
}, },
undefined, },
true, value: {
); "text/html": {
content: parsedValue,
// Parse emojis remote: false,
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,
},
},
});
self.source.fields.push({ self.source.fields.push({
name: field.name, name: field.name,
value: field.value, value: field.value,
}); });
}
} }
}
// Parse emojis // Parse emojis
const displaynameEmojis = const displaynameEmojis =
await Emoji.parseFromText(sanitizedDisplayName); await Emoji.parseFromText(sanitizedDisplayName);
const noteEmojis = await Emoji.parseFromText(self.note); const noteEmojis = await Emoji.parseFromText(self.note);
self.emojis = [ self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis].map(
...displaynameEmojis, (e) => e.data,
...noteEmojis, );
...fieldEmojis,
].map((e) => e.data);
// Deduplicate emojis // Deduplicate emojis
self.emojis = self.emojis.filter( self.emojis = self.emojis.filter(
(emoji, index, self) => (emoji, index, self) =>
self.findIndex((e) => e.id === emoji.id) === index, self.findIndex((e) => e.id === emoji.id) === index,
); );
// Connect emojis, if any // Connect emojis, if any
// Do it before updating user, so that federation takes that into account // Do it before updating user, so that federation takes that into account
for (const emoji of self.emojis) { for (const emoji of self.emojis) {
await db await db
.delete(EmojiToUser) .delete(EmojiToUser)
.where( .where(
and( and(
eq(EmojiToUser.emojiId, emoji.id), eq(EmojiToUser.emojiId, emoji.id),
eq(EmojiToUser.userId, self.id), eq(EmojiToUser.userId, self.id),
), ),
) )
.execute(); .execute();
await db await db
.insert(EmojiToUser) .insert(EmojiToUser)
.values({ .values({
emojiId: emoji.id, emojiId: emoji.id,
userId: self.id, userId: self.id,
}) })
.execute(); .execute();
} }
await user.update({ await user.update({
displayName: self.displayName, displayName: self.displayName,
username: self.username, username: self.username,
note: self.note, note: self.note,
avatar: self.avatar, avatar: self.avatar,
header: self.header, header: self.header,
fields: self.fields, fields: self.fields,
isLocked: self.isLocked, isLocked: self.isLocked,
isBot: self.isBot, isBot: self.isBot,
isDiscoverable: self.isDiscoverable, isDiscoverable: self.isDiscoverable,
source: self.source || undefined, source: self.source || undefined,
}); });
const output = await User.fromId(self.id); const output = await User.fromId(self.id);
if (!output) { if (!output) {
return context.json({ error: "Couldn't edit user" }, 500); return context.json({ error: "Couldn't edit user" }, 500);
} }
return context.json(output.toApi()); return context.json(output.toApi(), 200);
}, }),
),
); );

View file

@ -1,4 +1,7 @@
import { apiRoute, applyConfig, auth } from "@/api"; 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({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -13,20 +16,41 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute((app) => const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/accounts/verify_credentials",
meta.route, summary: "Verify credentials",
auth(meta.auth, meta.permissions), description: "Get your own account information",
(context) => { middleware: [auth(meta.auth)],
// TODO: Add checks for disabled/unverified accounts responses: {
const { user } = context.get("auth"); 200: {
description: "Account",
if (!user) { content: {
return context.json({ error: "Unauthorized" }, 401); "application/json": {
} schema: User.schema,
},
return context.json(user.toApi(true)); },
}, },
), 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);
}),
); );

View file

@ -1,6 +1,6 @@
import { apiRoute, applyConfig, handleZodError, jsonOrForm } from "@/api"; import { apiRoute, applyConfig, jsonOrForm } from "@/api";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import { z } from "zod"; import { z } from "zod";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { Applications, RolePermissions } from "~/drizzle/schema"; 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) => export default apiRoute((app) =>
app.on( app.openapi(route, async (context) => {
meta.allowedMethods, const { client_name, redirect_uris, scopes, website } =
meta.route, context.req.valid("json");
jsonOrForm(),
zValidator("json", schemas.json, handleZodError),
async (context) => {
const { client_name, redirect_uris, scopes, website } =
context.req.valid("json");
const app = ( const app = (
await db await db
.insert(Applications) .insert(Applications)
.values({ .values({
name: client_name || "", name: client_name || "",
redirectUri: decodeURIComponent(redirect_uris) || "", redirectUri: decodeURIComponent(redirect_uris) || "",
scopes: scopes || "read", scopes: scopes || "read",
website: website || null, website: website || null,
clientId: randomString(32, "base64url"), clientId: randomString(32, "base64url"),
secret: randomString(64, "base64url"), secret: randomString(64, "base64url"),
}) })
.returning() .returning()
)[0]; )[0];
return context.json({ return context.json(
{
id: app.id, id: app.id,
name: app.name, name: app.name,
website: app.website, website: app.website,
@ -74,7 +105,8 @@ export default apiRoute((app) =>
client_secret: app.secret, client_secret: app.secret,
redirect_uri: app.redirectUri, redirect_uri: app.redirectUri,
vapid_link: app.vapidKey, vapid_link: app.vapidKey,
}); },
}, 200,
), );
}),
); );

View file

@ -1,6 +1,8 @@
import { apiRoute, applyConfig, auth } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { getFromToken } from "~/classes/functions/application"; import { getFromToken } from "~/classes/functions/application";
import { RolePermissions } from "~/drizzle/schema"; import { RolePermissions } from "~/drizzle/schema";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], 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) => export default apiRoute((app) =>
app.on( app.openapi(route, async (context) => {
meta.allowedMethods, const { user, token } = context.get("auth");
meta.route,
auth(meta.auth, meta.permissions),
async (context) => {
const { user, token } = context.get("auth");
if (!token) { if (!token) {
return context.json({ error: "Unauthorized" }, 401); return context.json({ error: "Unauthorized" }, 401);
} }
if (!user) { if (!user) {
return context.json({ error: "Unauthorized" }, 401); return context.json({ error: "Unauthorized" }, 401);
} }
const application = await getFromToken(token); const application = await getFromToken(token);
if (!application) { if (!application) {
return context.json({ error: "Unauthorized" }, 401); return context.json({ error: "Unauthorized" }, 401);
} }
return context.json({ return context.json(
{
name: application.name, name: application.name,
website: application.website, website: application.website,
vapid_key: application.vapidKey, vapid_key: application.vapidKey,
redirect_uris: application.redirectUri, redirect_uris: application.redirectUri,
scopes: application.scopes, scopes: application.scopes,
}); },
}, 200,
), );
}),
); );

View file

@ -1,15 +1,11 @@
import { import { apiRoute, applyConfig, auth, idValidator } from "@/api";
apiRoute, import { createRoute } from "@hono/zod-openapi";
applyConfig,
auth,
handleZodError,
idValidator,
} from "@/api";
import { zValidator } from "@hono/zod-validator";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { RolePermissions, Users } from "~/drizzle/schema"; import { RolePermissions, Users } from "~/drizzle/schema";
import { Timeline } from "~/packages/database-interface/timeline"; import { Timeline } from "~/packages/database-interface/timeline";
import { User } from "~/packages/database-interface/user";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -36,40 +32,62 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/blocks",
meta.route, summary: "Get blocks",
zValidator("query", schemas.query, handleZodError), description: "Get users you have blocked",
auth(meta.auth, meta.permissions), middleware: [auth(meta.auth, meta.permissions)],
async (context) => { request: {
const { max_id, since_id, min_id, limit } = query: schemas.query,
context.req.valid("query"); },
responses: {
const { user } = context.get("auth"); 200: {
description: "Blocks",
if (!user) { content: {
return context.json({ error: "Unauthorized" }, 401); "application/json": {
} schema: z.array(User.schema),
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,
}, },
); },
}, },
), 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,
},
);
}),
); );

View file

@ -1,6 +1,8 @@
import { apiRoute, applyConfig, auth } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { generateChallenge } from "@/challenges"; import { generateChallenge } from "@/challenges";
import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], 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) => export default apiRoute((app) =>
app.on( app.openapi(route, async (context) => {
meta.allowedMethods, if (!config.validation.challenges.enabled) {
meta.route, return context.json(
auth(meta.auth, meta.permissions), { error: "Challenges are disabled in config" },
async (context) => { 400,
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, id: result.id,
...result.challenge, ...result.challenge,
}); },
}, 200,
), );
}),
); );

View file

@ -1,4 +1,5 @@
import { apiRoute, applyConfig, auth } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { Emojis, RolePermissions } from "~/drizzle/schema"; import { Emojis, RolePermissions } from "~/drizzle/schema";
import { Emoji } from "~/packages/database-interface/emoji"; import { Emoji } from "~/packages/database-interface/emoji";
@ -18,25 +19,41 @@ export const meta = applyConfig({
}, },
}); });
export default apiRoute((app) => const route = createRoute({
app.on( method: "get",
meta.allowedMethods, path: "/api/v1/custom_emojis",
meta.route, summary: "Get custom emojis",
auth(meta.auth, meta.permissions), description: "Get custom emojis",
async (context) => { middleware: [auth(meta.auth, meta.permissions)],
const { user } = context.get("auth"); responses: {
200: {
const emojis = await Emoji.manyFromSql( description: "Emojis",
and( content: {
isNull(Emojis.instanceId), "application/json": {
or( schema: z.array(Emoji.schema),
isNull(Emojis.ownerId), },
user ? eq(Emojis.ownerId, user.id) : undefined, },
),
),
);
return context.json(emojis.map((emoji) => emoji.toApi()));
}, },
), },
});
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,
);
}),
); );

View file

@ -1,13 +1,6 @@
import { import { apiRoute, applyConfig, auth, emojiValidator, jsonOrForm } from "@/api";
apiRoute,
applyConfig,
auth,
emojiValidator,
handleZodError,
jsonOrForm,
} from "@/api";
import { mimeLookup } from "@/content_types"; import { mimeLookup } from "@/content_types";
import { zValidator } from "@hono/zod-validator"; import { createRoute } from "@hono/zod-openapi";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { MediaManager } from "~/classes/media/media-manager"; import { MediaManager } from "~/classes/media/media-manager";
@ -15,6 +8,7 @@ import { Emojis, RolePermissions } from "~/drizzle/schema";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { Attachment } from "~/packages/database-interface/attachment"; import { Attachment } from "~/packages/database-interface/attachment";
import { Emoji } from "~/packages/database-interface/emoji"; import { Emoji } from "~/packages/database-interface/emoji";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -59,88 +53,128 @@ export const schemas = {
}), }),
}; };
export default apiRoute((app) => const route = createRoute({
app.on( method: "post",
meta.allowedMethods, path: "/api/v1/emojis",
meta.route, summary: "Upload emoji",
jsonOrForm(), description: "Upload an emoji",
zValidator("json", schemas.json, handleZodError), middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
auth(meta.auth, meta.permissions), request: {
async (context) => { body: {
const { shortcode, element, alt, global, category } = content: {
context.req.valid("json"); "application/json": {
const { user } = context.get("auth"); schema: schemas.json,
},
if (!user) { "multipart/form-data": {
return context.json({ error: "Unauthorized" }, 401); schema: schemas.json,
} },
"application/x-www-form-urlencoded": {
if (!user.hasPermission(RolePermissions.ManageEmojis) && global) { schema: schemas.json,
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());
}, },
), },
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);
}),
); );