mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
refactor(api): ♻️ Refactor more routes to use OpenAPI
This commit is contained in:
parent
5554038f44
commit
6ed1bd747f
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue