mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
refactor(api): ♻️ Move from @hono/zod-openapi to hono-openapi
hono-openapi is easier to work with and generates better OpenAPI definitions
This commit is contained in:
parent
0576aff972
commit
58342e86e1
|
|
@ -1,80 +1,16 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { Application, User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import type { Context } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { setCookie } from "hono/cookie";
|
||||
import { SignJWT } from "jose";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const schemas = {
|
||||
form: z.object({
|
||||
identifier: z
|
||||
.string()
|
||||
.email()
|
||||
.toLowerCase()
|
||||
.or(z.string().toLowerCase()),
|
||||
password: z.string().min(2).max(100),
|
||||
}),
|
||||
query: z.object({
|
||||
scope: z.string().optional(),
|
||||
redirect_uri: z.string().url().optional(),
|
||||
response_type: z.enum([
|
||||
"code",
|
||||
"token",
|
||||
"none",
|
||||
"id_token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"token id_token",
|
||||
"code token id_token",
|
||||
]),
|
||||
client_id: z.string(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
||||
prompt: z
|
||||
.enum(["none", "login", "consent", "select_account"])
|
||||
.optional()
|
||||
.default("none"),
|
||||
max_age: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.default(60 * 60 * 24 * 7),
|
||||
}),
|
||||
};
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/auth/login",
|
||||
summary: "Login",
|
||||
description: "Login to the application",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: schemas.form,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: schemas.query,
|
||||
},
|
||||
responses: {
|
||||
302: {
|
||||
description: "Redirect to OAuth authorize, or error",
|
||||
headers: {
|
||||
"Set-Cookie": {
|
||||
description: "JWT cookie",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const returnError = (
|
||||
context: Context,
|
||||
error: string,
|
||||
|
|
@ -101,126 +37,194 @@ const returnError = (
|
|||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
|
||||
| {
|
||||
forced: boolean;
|
||||
providers: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}[];
|
||||
keys: {
|
||||
private: string;
|
||||
public: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!oidcConfig) {
|
||||
return returnError(
|
||||
context,
|
||||
"invalid_request",
|
||||
"The OpenID Connect plugin is not enabled on this instance. Cannot process login request.",
|
||||
);
|
||||
}
|
||||
|
||||
if (oidcConfig?.forced) {
|
||||
return returnError(
|
||||
context,
|
||||
"invalid_request",
|
||||
"Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
|
||||
);
|
||||
}
|
||||
|
||||
const { identifier, password } = context.req.valid("form");
|
||||
const { client_id } = context.req.valid("query");
|
||||
|
||||
// Find user
|
||||
const user = await User.fromSql(
|
||||
or(
|
||||
eq(Users.email, identifier.toLowerCase()),
|
||||
eq(Users.username, identifier.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
!(
|
||||
user &&
|
||||
(await Bun.password.verify(password, user.data.password || ""))
|
||||
)
|
||||
) {
|
||||
return returnError(
|
||||
context,
|
||||
"invalid_grant",
|
||||
"Invalid identifier or password",
|
||||
);
|
||||
}
|
||||
|
||||
if (user.data.passwordResetToken) {
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.password_reset}?${new URLSearchParams(
|
||||
{
|
||||
token: user.data.passwordResetToken ?? "",
|
||||
login_reset: "true",
|
||||
app.post(
|
||||
"/api/auth/login",
|
||||
describeRoute({
|
||||
summary: "Login",
|
||||
description: "Login to the application",
|
||||
responses: {
|
||||
302: {
|
||||
description: "Redirect to OAuth authorize, or error",
|
||||
headers: {
|
||||
"Set-Cookie": {
|
||||
description: "JWT cookie",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
).toString()}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
scope: z.string().optional(),
|
||||
redirect_uri: z.string().url().optional(),
|
||||
response_type: z.enum([
|
||||
"code",
|
||||
"token",
|
||||
"none",
|
||||
"id_token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"token id_token",
|
||||
"code token id_token",
|
||||
]),
|
||||
client_id: z.string(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
||||
prompt: z
|
||||
.enum(["none", "login", "consent", "select_account"])
|
||||
.optional()
|
||||
.default("none"),
|
||||
max_age: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.default(60 * 60 * 24 * 7),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
validator(
|
||||
"form",
|
||||
z.object({
|
||||
identifier: z
|
||||
.string()
|
||||
.email()
|
||||
.toLowerCase()
|
||||
.or(z.string().toLowerCase()),
|
||||
password: z.string().min(2).max(100),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
|
||||
| {
|
||||
forced: boolean;
|
||||
providers: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}[];
|
||||
keys: {
|
||||
private: string;
|
||||
public: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Try and import the key
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(oidcConfig?.keys?.private ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
// Generate JWT
|
||||
const jwt = await new SignJWT({
|
||||
sub: user.id,
|
||||
iss: config.http.base_url.origin,
|
||||
aud: client_id,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
|
||||
const application = await Application.fromClientId(client_id);
|
||||
|
||||
if (!application) {
|
||||
throw new ApiError(400, "Invalid application");
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
application: application.data.name,
|
||||
});
|
||||
|
||||
if (application.data.website) {
|
||||
searchParams.append("website", application.data.website);
|
||||
}
|
||||
|
||||
// Add all data that is not undefined except email and password
|
||||
for (const [key, value] of Object.entries(context.req.query())) {
|
||||
if (key !== "email" && key !== "password" && value !== undefined) {
|
||||
searchParams.append(key, String(value));
|
||||
if (!oidcConfig) {
|
||||
return returnError(
|
||||
context,
|
||||
"invalid_request",
|
||||
"The OpenID Connect plugin is not enabled on this instance. Cannot process login request.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to OAuth authorize with JWT
|
||||
setCookie(context, "jwt", jwt, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Strict",
|
||||
path: "/",
|
||||
// 2 weeks
|
||||
maxAge: 60 * 60 * 24 * 14,
|
||||
});
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.consent}?${searchParams.toString()}`,
|
||||
);
|
||||
}),
|
||||
if (oidcConfig?.forced) {
|
||||
return returnError(
|
||||
context,
|
||||
"invalid_request",
|
||||
"Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
|
||||
);
|
||||
}
|
||||
|
||||
const { identifier, password } = context.req.valid("form");
|
||||
const { client_id } = context.req.valid("query");
|
||||
|
||||
// Find user
|
||||
const user = await User.fromSql(
|
||||
or(
|
||||
eq(Users.email, identifier.toLowerCase()),
|
||||
eq(Users.username, identifier.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
!(
|
||||
user &&
|
||||
(await Bun.password.verify(
|
||||
password,
|
||||
user.data.password || "",
|
||||
))
|
||||
)
|
||||
) {
|
||||
return returnError(
|
||||
context,
|
||||
"invalid_grant",
|
||||
"Invalid identifier or password",
|
||||
);
|
||||
}
|
||||
|
||||
if (user.data.passwordResetToken) {
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.password_reset}?${new URLSearchParams(
|
||||
{
|
||||
token: user.data.passwordResetToken ?? "",
|
||||
login_reset: "true",
|
||||
},
|
||||
).toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Try and import the key
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(oidcConfig?.keys?.private ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
// Generate JWT
|
||||
const jwt = await new SignJWT({
|
||||
sub: user.id,
|
||||
iss: config.http.base_url.origin,
|
||||
aud: client_id,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
|
||||
const application = await Application.fromClientId(client_id);
|
||||
|
||||
if (!application) {
|
||||
throw new ApiError(400, "Invalid application");
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
application: application.data.name,
|
||||
});
|
||||
|
||||
if (application.data.website) {
|
||||
searchParams.append("website", application.data.website);
|
||||
}
|
||||
|
||||
// Add all data that is not undefined except email and password
|
||||
for (const [key, value] of Object.entries(context.req.query())) {
|
||||
if (
|
||||
key !== "email" &&
|
||||
key !== "password" &&
|
||||
value !== undefined
|
||||
) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to OAuth authorize with JWT
|
||||
setCookie(context, "jwt", jwt, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Strict",
|
||||
path: "/",
|
||||
// 2 weeks
|
||||
maxAge: 60 * 60 * 24 * 14,
|
||||
});
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.consent}?${searchParams.toString()}`,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,72 +1,76 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { db } from "@versia/kit/db";
|
||||
import { Applications, Tokens } from "@versia/kit/tables";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const schemas = {
|
||||
query: z.object({
|
||||
redirect_uri: z.string().url(),
|
||||
client_id: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/auth/redirect",
|
||||
summary: "OAuth Code flow",
|
||||
description:
|
||||
"Redirects to the application, or back to login if the code is invalid",
|
||||
tags: ["OpenID"],
|
||||
responses: {
|
||||
302: {
|
||||
description:
|
||||
"Redirects to the application, or back to login if the code is invalid",
|
||||
},
|
||||
},
|
||||
request: {
|
||||
query: schemas.query,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* OAuth Code flow
|
||||
*/
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { redirect_uri, client_id, code } = context.req.valid("query");
|
||||
app.get(
|
||||
"/api/auth/redirect",
|
||||
describeRoute({
|
||||
summary: "OAuth Code flow",
|
||||
description:
|
||||
"Redirects to the application, or back to login if the code is invalid",
|
||||
tags: ["OpenID"],
|
||||
responses: {
|
||||
302: {
|
||||
description:
|
||||
"Redirects to the application, or back to login if the code is invalid",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
redirect_uri: z.string().url(),
|
||||
client_id: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { redirect_uri, client_id, code } =
|
||||
context.req.valid("query");
|
||||
|
||||
const redirectToLogin = (error: string): Response =>
|
||||
context.redirect(
|
||||
`${config.frontend.routes.login}?${new URLSearchParams({
|
||||
...context.req.query,
|
||||
error: encodeURIComponent(error),
|
||||
const redirectToLogin = (error: string): Response =>
|
||||
context.redirect(
|
||||
`${config.frontend.routes.login}?${new URLSearchParams({
|
||||
...context.req.query,
|
||||
error: encodeURIComponent(error),
|
||||
}).toString()}`,
|
||||
);
|
||||
|
||||
const foundToken = await db
|
||||
.select()
|
||||
.from(Tokens)
|
||||
.leftJoin(
|
||||
Applications,
|
||||
eq(Tokens.applicationId, Applications.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(Tokens.code, code),
|
||||
eq(Applications.clientId, client_id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!foundToken || foundToken.length <= 0) {
|
||||
return redirectToLogin("Invalid code");
|
||||
}
|
||||
|
||||
// Redirect back to application
|
||||
return context.redirect(
|
||||
`${redirect_uri}?${new URLSearchParams({
|
||||
code,
|
||||
}).toString()}`,
|
||||
);
|
||||
|
||||
const foundToken = await db
|
||||
.select()
|
||||
.from(Tokens)
|
||||
.leftJoin(Applications, eq(Tokens.applicationId, Applications.id))
|
||||
.where(
|
||||
and(
|
||||
eq(Tokens.code, code),
|
||||
eq(Applications.clientId, client_id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!foundToken || foundToken.length <= 0) {
|
||||
return redirectToLogin("Invalid code");
|
||||
}
|
||||
|
||||
// Redirect back to application
|
||||
return context.redirect(
|
||||
`${redirect_uri}?${new URLSearchParams({
|
||||
code,
|
||||
}).toString()}`,
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,42 +1,13 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Context } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const schemas = {
|
||||
form: z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(3).max(100),
|
||||
}),
|
||||
};
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/auth/reset",
|
||||
summary: "Reset password",
|
||||
description: "Reset password",
|
||||
responses: {
|
||||
302: {
|
||||
description: "Redirect to the password reset page with a message",
|
||||
},
|
||||
},
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/x-www-form-urlencoded": {
|
||||
schema: schemas.form,
|
||||
},
|
||||
"multipart/form-data": {
|
||||
schema: schemas.form,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const returnError = (
|
||||
context: Context,
|
||||
token: string,
|
||||
|
|
@ -60,27 +31,50 @@ const returnError = (
|
|||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { token, password } = context.req.valid("form");
|
||||
app.post(
|
||||
"/api/auth/reset",
|
||||
describeRoute({
|
||||
summary: "Reset password",
|
||||
description: "Reset password",
|
||||
responses: {
|
||||
302: {
|
||||
description:
|
||||
"Redirect to the password reset page with a message",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"form",
|
||||
z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(3).max(100),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { token, password } = context.req.valid("form");
|
||||
|
||||
const user = await User.fromSql(eq(Users.passwordResetToken, token));
|
||||
|
||||
if (!user) {
|
||||
return returnError(
|
||||
context,
|
||||
token,
|
||||
"invalid_token",
|
||||
"Invalid token",
|
||||
const user = await User.fromSql(
|
||||
eq(Users.passwordResetToken, token),
|
||||
);
|
||||
}
|
||||
|
||||
await user.update({
|
||||
password: await Bun.password.hash(password),
|
||||
passwordResetToken: null,
|
||||
});
|
||||
if (!user) {
|
||||
return returnError(
|
||||
context,
|
||||
token,
|
||||
"invalid_token",
|
||||
"Invalid token",
|
||||
);
|
||||
}
|
||||
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.password_reset}?success=true`,
|
||||
);
|
||||
}),
|
||||
await user.update({
|
||||
password: await Bun.password.hash(password),
|
||||
passwordResetToken: null,
|
||||
});
|
||||
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.password_reset}?success=true`,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/block",
|
||||
summary: "Block account",
|
||||
description:
|
||||
"Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#block",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:blocks"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnBlocks,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully blocked, or account was already blocked.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (!foundRelationship.data.blocking) {
|
||||
await foundRelationship.update({
|
||||
blocking: true,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
iso631,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/follow",
|
||||
summary: "Follow account",
|
||||
description:
|
||||
"Follow the given account. Can also be used to update whether to show reblogs or enable notifications.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#follow",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:follows"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully followed, or account was already followed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description:
|
||||
"Trying to follow someone that you block or that blocks you",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
reblogs: z.boolean().default(true).openapi({
|
||||
description:
|
||||
"Receive this account’s reblogs in home timeline?",
|
||||
example: true,
|
||||
}),
|
||||
notify: z.boolean().default(false).openapi({
|
||||
description:
|
||||
"Receive notifications when this account posts a status?",
|
||||
example: false,
|
||||
}),
|
||||
languages: z
|
||||
.array(iso631)
|
||||
.default([])
|
||||
.openapi({
|
||||
description:
|
||||
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.",
|
||||
example: ["en", "fr"],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { reblogs, notify, languages } = context.req.valid("json");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
let relationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (!relationship.data.following) {
|
||||
relationship = await user.followRequest(otherUser, {
|
||||
reblogs,
|
||||
notify,
|
||||
languages,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(relationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/{id}/followers",
|
||||
summary: "Get account’s followers",
|
||||
description:
|
||||
"Accounts which follow the given account, if network is not hidden by the account owner.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#followers",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
scopes: ["read:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ViewAccountFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
query: z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Accounts which follow the given account.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(AccountSchema),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description: "Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/followers?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/followers?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { max_id, since_id, min_id, limit } = context.req.valid("query");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
// TODO: Add follower/following privacy settings
|
||||
const { objects, 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" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((object) => object.toApi())),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/{id}/following",
|
||||
summary: "Get account’s following",
|
||||
description:
|
||||
"Accounts which the given account is following, if network is not hidden by the account owner.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#following",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
scopes: ["read:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ViewAccountFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
query: z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Accounts which the given account is following.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(AccountSchema),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description: "Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/following?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/following?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { max_id, since_id, min_id } = context.req.valid("query");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
// TODO: Add follower/following privacy settings
|
||||
|
||||
const { objects, 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" = ${otherUser.id} AND "Relationships"."following" = true)`,
|
||||
),
|
||||
context.req.valid("query").limit,
|
||||
new URL(context.req.url),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((object) => object.toApi())),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/{id}",
|
||||
summary: "Get account",
|
||||
description: "View information about a profile.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#get",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
permissions: [RolePermission.ViewAccounts],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"The Account record will be returned. Note that acct of local users does not include the domain name.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: AccountSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
return context.json(otherUser.toApi(user?.id === otherUser.id), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/mute",
|
||||
summary: "Mute account",
|
||||
description:
|
||||
"Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#mute",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:mutes"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnMutes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
notifications: z.boolean().default(true).openapi({
|
||||
description:
|
||||
"Mute notifications in addition to statuses?",
|
||||
}),
|
||||
duration: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60 * 60 * 24 * 365 * 5)
|
||||
.default(0)
|
||||
.openapi({
|
||||
description:
|
||||
"How long the mute should last, in seconds.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully muted, or account was already muted. Note that you can call this API method again with notifications=false to update the relationship so that only statuses are muted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
// TODO: Add duration support
|
||||
const { notifications } = context.req.valid("json");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
// TODO: Implement duration
|
||||
await foundRelationship.update({
|
||||
muting: true,
|
||||
mutingNotifications: notifications,
|
||||
});
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/note",
|
||||
summary: "Set private note on profile",
|
||||
description: "Sets a private note on a user.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#note",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnAccount,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
comment: RelationshipSchema.shape.note
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated profile note",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { comment } = context.req.valid("json");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
await foundRelationship.update({
|
||||
note: comment ?? "",
|
||||
});
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/pin",
|
||||
summary: "Feature account on your profile",
|
||||
description:
|
||||
"Add the given account to the user’s featured profiles. (Featured profiles are currently shown on the user’s own public profile.)",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#pin",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnAccount,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated relationship",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
await foundRelationship.update({
|
||||
endorsed: true,
|
||||
});
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/refetch",
|
||||
summary: "Refetch account",
|
||||
description: "Refetch the given account's profile from the remote server",
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:accounts"],
|
||||
permissions: [RolePermission.ViewAccounts],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Refetched account data",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: AccountSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "User is local",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const otherUser = context.get("user");
|
||||
|
||||
if (otherUser.isLocal()) {
|
||||
throw new ApiError(400, "Cannot refetch a local user");
|
||||
}
|
||||
|
||||
const newUser = await otherUser.updateFromRemote();
|
||||
|
||||
return context.json(newUser.toApi(false), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/remove_from_followers",
|
||||
summary: "Remove account from followers",
|
||||
description: "Remove the given account from your followers.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#remove_from_followers",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:follows"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully removed from followers, or account was already not following you",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
||||
otherUser,
|
||||
user,
|
||||
);
|
||||
|
||||
if (oppositeRelationship.data.following) {
|
||||
await oppositeRelationship.update({
|
||||
following: false,
|
||||
});
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Role as RoleSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Role } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const routePost = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/roles/{role_id}",
|
||||
summary: "Assign role to account",
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageRoles],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
role_id: RoleSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: "Role assigned",
|
||||
},
|
||||
404: ApiError.roleNotFound().schema,
|
||||
403: ApiError.forbidden().schema,
|
||||
},
|
||||
});
|
||||
|
||||
const routeDelete = createRoute({
|
||||
method: "delete",
|
||||
path: "/api/v1/accounts/{id}/roles/{role_id}",
|
||||
summary: "Remove role from user",
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageRoles],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
role_id: RoleSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: "Role removed",
|
||||
},
|
||||
404: ApiError.roleNotFound().schema,
|
||||
403: ApiError.forbidden().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.openapi(routePost, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { role_id } = context.req.valid("param");
|
||||
const targetUser = context.get("user");
|
||||
|
||||
const role = await Role.fromId(role_id);
|
||||
|
||||
if (!role) {
|
||||
throw ApiError.roleNotFound();
|
||||
}
|
||||
// Priority check
|
||||
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
|
||||
|
||||
const userHighestRole = userRoles.reduce((prev, current) =>
|
||||
prev.data.priority > current.data.priority ? prev : current,
|
||||
);
|
||||
|
||||
if (role.data.priority > userHighestRole.data.priority) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
"Forbidden",
|
||||
`User with highest role priority ${userHighestRole.data.priority} cannot assign role with priority ${role.data.priority}`,
|
||||
);
|
||||
}
|
||||
|
||||
await role.linkUser(targetUser.id);
|
||||
|
||||
return context.body(null, 204);
|
||||
});
|
||||
|
||||
app.openapi(routeDelete, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { role_id } = context.req.valid("param");
|
||||
const targetUser = context.get("user");
|
||||
|
||||
const role = await Role.fromId(role_id);
|
||||
|
||||
if (!role) {
|
||||
throw ApiError.roleNotFound();
|
||||
}
|
||||
|
||||
// Priority check
|
||||
const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin);
|
||||
|
||||
const userHighestRole = userRoles.reduce((prev, current) =>
|
||||
prev.data.priority > current.data.priority ? prev : current,
|
||||
);
|
||||
|
||||
if (role.data.priority > userHighestRole.data.priority) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
"Forbidden",
|
||||
`User with highest role priority ${userHighestRole.data.priority} cannot remove role with priority ${role.data.priority}`,
|
||||
);
|
||||
}
|
||||
|
||||
await role.unlinkUser(targetUser.id);
|
||||
|
||||
return context.body(null, 204);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Role as RoleSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { Role } from "@versia/kit/db";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/{id}/roles",
|
||||
summary: "List account roles",
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of roles",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(RoleSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.openapi(route, async (context) => {
|
||||
const targetUser = context.get("user");
|
||||
|
||||
const roles = await Role.getUserRoles(
|
||||
targetUser.id,
|
||||
targetUser.data.isAdmin,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
roles.map((role) => role.toApi()),
|
||||
200,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Status as StatusSchema,
|
||||
zBoolean,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Notes } from "@versia/kit/tables";
|
||||
import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/{id}/statuses",
|
||||
summary: "Get account’s statuses",
|
||||
description: "Statuses posted to the given account.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#statuses",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
permissions: [
|
||||
RolePermission.ViewNotes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
scopes: ["read:statuses"],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
query: z.object({
|
||||
max_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce.number().int().min(1).max(40).default(20).openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
only_media: zBoolean.default(false).openapi({
|
||||
description: "Filter out statuses without attachments.",
|
||||
}),
|
||||
exclude_replies: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Filter out statuses in reply to a different account.",
|
||||
}),
|
||||
exclude_reblogs: zBoolean.default(false).openapi({
|
||||
description: "Filter out boosts from the response.",
|
||||
}),
|
||||
pinned: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Filter for pinned statuses only. Pinned statuses do not receive special priority in the order of the returned results.",
|
||||
}),
|
||||
tagged: z.string().optional().openapi({
|
||||
description: "Filter for statuses using a specific hashtag.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Statuses posted to the given account.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(StatusSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const {
|
||||
max_id,
|
||||
min_id,
|
||||
since_id,
|
||||
limit,
|
||||
exclude_reblogs,
|
||||
only_media,
|
||||
exclude_replies,
|
||||
pinned,
|
||||
} = context.req.valid("query");
|
||||
|
||||
const { objects } = await Timeline.getNoteTimeline(
|
||||
and(
|
||||
max_id ? lt(Notes.id, max_id) : undefined,
|
||||
since_id ? gte(Notes.id, since_id) : undefined,
|
||||
min_id ? gt(Notes.id, min_id) : undefined,
|
||||
eq(Notes.authorId, otherUser.id),
|
||||
only_media
|
||||
? sql`EXISTS (SELECT 1 FROM "Medias" WHERE "Medias"."noteId" = ${Notes.id})`
|
||||
: undefined,
|
||||
pinned
|
||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
|
||||
: undefined,
|
||||
// Visibility check
|
||||
or(
|
||||
sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${otherUser.id})`,
|
||||
and(
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
|
||||
inArray(Notes.visibility, ["public", "private"]),
|
||||
),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
exclude_reblogs ? isNull(Notes.reblogId) : undefined,
|
||||
exclude_replies ? isNull(Notes.replyId) : undefined,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
user?.id,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((note) => note.toApi(otherUser))),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/unblock",
|
||||
summary: "Unblock account",
|
||||
description: "Unblock the given account.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#unblock",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:blocks"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnBlocks,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully unblocked, or account was already not blocked",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (foundRelationship.data.blocking) {
|
||||
await foundRelationship.update({
|
||||
blocking: false,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/unfollow",
|
||||
summary: "Unfollow account",
|
||||
description: "Unfollow the given account.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#unfollow",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:follows"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully unfollowed, or account was already not followed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
await user.unfollow(otherUser, foundRelationship);
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/unmute",
|
||||
summary: "Unmute account",
|
||||
description: "Unmute the given account.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#unmute",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:mutes"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnMutes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully unmuted, or account was already unmuted",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (foundRelationship.data.muting) {
|
||||
await foundRelationship.update({
|
||||
muting: false,
|
||||
mutingNotifications: false,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts/{id}/unpin",
|
||||
summary: "Unfeature account from profile",
|
||||
description: "Remove the given account from the user’s featured profiles.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#unpin",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnAccount,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
withUserParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully unendorsed, or account was already not endorsed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (foundRelationship.data.endorsed) {
|
||||
await foundRelationship.update({
|
||||
endorsed: false,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
62
api/api/v1/accounts/[id]/block.ts
Normal file
62
api/api/v1/accounts/[id]/block.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/block",
|
||||
describeRoute({
|
||||
summary: "Block account",
|
||||
description:
|
||||
"Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#block",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully blocked, or account was already blocked.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:blocks"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnBlocks,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (!foundRelationship.data.blocking) {
|
||||
await foundRelationship.update({
|
||||
blocking: true,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
102
api/api/v1/accounts/[id]/follow.ts
Normal file
102
api/api/v1/accounts/[id]/follow.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import {
|
||||
Relationship as RelationshipSchema,
|
||||
iso631,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/follow",
|
||||
describeRoute({
|
||||
summary: "Follow account",
|
||||
description:
|
||||
"Follow the given account. Can also be used to update whether to show reblogs or enable notifications.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#follow",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully followed, or account was already followed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description:
|
||||
"Trying to follow someone that you block or that blocks you",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:follows"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
reblogs: z.boolean().default(true).openapi({
|
||||
description:
|
||||
"Receive this account’s reblogs in home timeline?",
|
||||
example: true,
|
||||
}),
|
||||
notify: z.boolean().default(false).openapi({
|
||||
description:
|
||||
"Receive notifications when this account posts a status?",
|
||||
example: false,
|
||||
}),
|
||||
languages: z
|
||||
.array(iso631)
|
||||
.default([])
|
||||
.openapi({
|
||||
description:
|
||||
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.",
|
||||
example: ["en", "fr"],
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { reblogs, notify, languages } = context.req.valid("json");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
let relationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (!relationship.data.following) {
|
||||
relationship = await user.followRequest(otherUser, {
|
||||
reblogs,
|
||||
notify,
|
||||
languages,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(relationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
111
api/api/v1/accounts/[id]/followers.ts
Normal file
111
api/api/v1/accounts/[id]/followers.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/accounts/:id/followers",
|
||||
describeRoute({
|
||||
summary: "Get account’s followers",
|
||||
description:
|
||||
"Accounts which follow the given account, if network is not hidden by the account owner.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#followers",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Accounts which follow the given account.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(AccountSchema)),
|
||||
},
|
||||
},
|
||||
headers: resolver(
|
||||
z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/followers?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/followers?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: false,
|
||||
scopes: ["read:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ViewAccountFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { max_id, since_id, min_id, limit } =
|
||||
context.req.valid("query");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
// TODO: Add follower/following privacy settings
|
||||
const { objects, 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" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((object) => object.toApi())),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
112
api/api/v1/accounts/[id]/following.ts
Normal file
112
api/api/v1/accounts/[id]/following.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/accounts/:id/following",
|
||||
describeRoute({
|
||||
summary: "Get account’s following",
|
||||
description:
|
||||
"Accounts which the given account is following, if network is not hidden by the account owner.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#following",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Accounts which the given account is following.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(AccountSchema)),
|
||||
},
|
||||
},
|
||||
headers: resolver(
|
||||
z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/following?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/accounts/46be88d3-25b4-4edc-8be9-c28c4ac5ea95/following?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: false,
|
||||
scopes: ["read:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ViewAccountFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { max_id, since_id, min_id } = context.req.valid("query");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
// TODO: Add follower/following privacy settings
|
||||
|
||||
const { objects, 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" = ${otherUser.id} AND "Relationships"."following" = true)`,
|
||||
),
|
||||
context.req.valid("query").limit,
|
||||
new URL(context.req.url),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((object) => object.toApi())),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
47
api/api/v1/accounts/[id]/index.ts
Normal file
47
api/api/v1/accounts/[id]/index.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/accounts/:id",
|
||||
describeRoute({
|
||||
summary: "Get account",
|
||||
description: "View information about a profile.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#get",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"The Account record will be returned. Note that acct of local users does not include the domain name.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(AccountSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: false,
|
||||
permissions: [RolePermission.ViewAccounts],
|
||||
}),
|
||||
(context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
return context.json(
|
||||
otherUser.toApi(user?.id === otherUser.id),
|
||||
200,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
84
api/api/v1/accounts/[id]/mute.ts
Normal file
84
api/api/v1/accounts/[id]/mute.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/mute",
|
||||
describeRoute({
|
||||
summary: "Mute account",
|
||||
description:
|
||||
"Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#mute",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully muted, or account was already muted. Note that you can call this API method again with notifications=false to update the relationship so that only statuses are muted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:mutes"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnMutes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
notifications: z.boolean().default(true).openapi({
|
||||
description: "Mute notifications in addition to statuses?",
|
||||
}),
|
||||
duration: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60 * 60 * 24 * 365 * 5)
|
||||
.default(0)
|
||||
.openapi({
|
||||
description:
|
||||
"How long the mute should last, in seconds.",
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
// TODO: Add duration support
|
||||
const { notifications } = context.req.valid("json");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
// TODO: Implement duration
|
||||
await foundRelationship.update({
|
||||
muting: true,
|
||||
mutingNotifications: notifications,
|
||||
});
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
70
api/api/v1/accounts/[id]/note.ts
Normal file
70
api/api/v1/accounts/[id]/note.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/note",
|
||||
describeRoute({
|
||||
summary: "Set private note on profile",
|
||||
description: "Sets a private note on a user.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#note",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated profile note",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnAccount,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
comment: RelationshipSchema.shape.note.optional().openapi({
|
||||
description:
|
||||
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { comment } = context.req.valid("json");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
await foundRelationship.update({
|
||||
note: comment ?? "",
|
||||
});
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
54
api/api/v1/accounts/[id]/pin.ts
Normal file
54
api/api/v1/accounts/[id]/pin.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/pin",
|
||||
describeRoute({
|
||||
summary: "Feature account on your profile",
|
||||
description:
|
||||
"Add the given account to the user’s featured profiles. (Featured profiles are currently shown on the user’s own public profile.)",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#pin",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated relationship",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnAccount,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
await foundRelationship.update({
|
||||
endorsed: true,
|
||||
});
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
56
api/api/v1/accounts/[id]/refetch.ts
Normal file
56
api/api/v1/accounts/[id]/refetch.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/refetch",
|
||||
describeRoute({
|
||||
summary: "Refetch account",
|
||||
description:
|
||||
"Refetch the given account's profile from the remote server",
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Refetched account data",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(AccountSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "User is local",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:accounts"],
|
||||
permissions: [RolePermission.ViewAccounts],
|
||||
}),
|
||||
async (context) => {
|
||||
const otherUser = context.get("user");
|
||||
|
||||
if (otherUser.isLocal()) {
|
||||
throw new ApiError(400, "Cannot refetch a local user");
|
||||
}
|
||||
|
||||
const newUser = await otherUser.updateFromRemote();
|
||||
|
||||
return context.json(newUser.toApi(false), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
66
api/api/v1/accounts/[id]/remove_from_followers.ts
Normal file
66
api/api/v1/accounts/[id]/remove_from_followers.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/remove_from_followers",
|
||||
describeRoute({
|
||||
summary: "Remove account from followers",
|
||||
description: "Remove the given account from your followers.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#remove_from_followers",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully removed from followers, or account was already not following you",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:follows"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
||||
otherUser,
|
||||
user,
|
||||
);
|
||||
|
||||
if (oppositeRelationship.data.following) {
|
||||
await oppositeRelationship.update({
|
||||
following: false,
|
||||
});
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
127
api/api/v1/accounts/[id]/roles/[role_id]/index.ts
Normal file
127
api/api/v1/accounts/[id]/roles/[role_id]/index.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Role as RoleSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Role } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/roles/:role_id",
|
||||
describeRoute({
|
||||
summary: "Assign role to account",
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
204: {
|
||||
description: "Role assigned",
|
||||
},
|
||||
404: ApiError.roleNotFound().schema,
|
||||
403: ApiError.forbidden().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
role_id: RoleSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageRoles],
|
||||
}),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { role_id } = context.req.valid("param");
|
||||
const targetUser = context.get("user");
|
||||
|
||||
const role = await Role.fromId(role_id);
|
||||
|
||||
if (!role) {
|
||||
throw ApiError.roleNotFound();
|
||||
}
|
||||
// Priority check
|
||||
const userRoles = await Role.getUserRoles(
|
||||
user.id,
|
||||
user.data.isAdmin,
|
||||
);
|
||||
|
||||
const userHighestRole = userRoles.reduce((prev, current) =>
|
||||
prev.data.priority > current.data.priority ? prev : current,
|
||||
);
|
||||
|
||||
if (role.data.priority > userHighestRole.data.priority) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
"Forbidden",
|
||||
`User with highest role priority ${userHighestRole.data.priority} cannot assign role with priority ${role.data.priority}`,
|
||||
);
|
||||
}
|
||||
|
||||
await role.linkUser(targetUser.id);
|
||||
|
||||
return context.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/api/v1/accounts/:id/roles/:role_id",
|
||||
describeRoute({
|
||||
summary: "Remove role from user",
|
||||
tags: ["Accounts"],
|
||||
}),
|
||||
withUserParam,
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: AccountSchema.shape.id,
|
||||
role_id: RoleSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageRoles],
|
||||
}),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { role_id } = context.req.valid("param");
|
||||
const targetUser = context.get("user");
|
||||
|
||||
const role = await Role.fromId(role_id);
|
||||
|
||||
if (!role) {
|
||||
throw ApiError.roleNotFound();
|
||||
}
|
||||
|
||||
// Priority check
|
||||
const userRoles = await Role.getUserRoles(
|
||||
user.id,
|
||||
user.data.isAdmin,
|
||||
);
|
||||
|
||||
const userHighestRole = userRoles.reduce((prev, current) =>
|
||||
prev.data.priority > current.data.priority ? prev : current,
|
||||
);
|
||||
|
||||
if (role.data.priority > userHighestRole.data.priority) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
"Forbidden",
|
||||
`User with highest role priority ${userHighestRole.data.priority} cannot remove role with priority ${role.data.priority}`,
|
||||
);
|
||||
}
|
||||
|
||||
await role.unlinkUser(targetUser.id);
|
||||
|
||||
return context.body(null, 204);
|
||||
},
|
||||
);
|
||||
});
|
||||
43
api/api/v1/accounts/[id]/roles/index.ts
Normal file
43
api/api/v1/accounts/[id]/roles/index.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Role as RoleSchema } from "@versia/client/schemas";
|
||||
import { Role } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.get(
|
||||
"/api/v1/accounts/:id/roles",
|
||||
describeRoute({
|
||||
summary: "List account roles",
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of roles",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(RoleSchema)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: false,
|
||||
}),
|
||||
async (context) => {
|
||||
const targetUser = context.get("user");
|
||||
|
||||
const roles = await Role.getUserRoles(
|
||||
targetUser.id,
|
||||
targetUser.data.isAdmin,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
roles.map((role) => role.toApi()),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -95,7 +95,7 @@ describe("/api/v1/accounts/:id/statuses", () => {
|
|||
|
||||
expect(ok2).toBe(true);
|
||||
|
||||
const { data: data2, ok: ok3 } = await client0.getAccountStatuses(
|
||||
const { data: data3, ok: ok3 } = await client0.getAccountStatuses(
|
||||
users[1].id,
|
||||
{
|
||||
pinned: true,
|
||||
|
|
@ -103,7 +103,7 @@ describe("/api/v1/accounts/:id/statuses", () => {
|
|||
);
|
||||
|
||||
expect(ok3).toBe(true);
|
||||
expect(data2).toBeArrayOfSize(1);
|
||||
expect(data2[0].id).toBe(timeline[3].id);
|
||||
expect(data3).toBeArrayOfSize(1);
|
||||
expect(data3[0].id).toBe(timeline[3].id);
|
||||
});
|
||||
});
|
||||
142
api/api/v1/accounts/[id]/statuses.ts
Normal file
142
api/api/v1/accounts/[id]/statuses.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { Status as StatusSchema, zBoolean } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Notes } from "@versia/kit/tables";
|
||||
import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/accounts/:id/statuses",
|
||||
describeRoute({
|
||||
summary: "Get account’s statuses",
|
||||
description: "Statuses posted to the given account.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#statuses",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Statuses posted to the given account.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(StatusSchema)),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: false,
|
||||
permissions: [
|
||||
RolePermission.ViewNotes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
scopes: ["read:statuses"],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.default(20)
|
||||
.openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
only_media: zBoolean.default(false).openapi({
|
||||
description: "Filter out statuses without attachments.",
|
||||
}),
|
||||
exclude_replies: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Filter out statuses in reply to a different account.",
|
||||
}),
|
||||
exclude_reblogs: zBoolean.default(false).openapi({
|
||||
description: "Filter out boosts from the response.",
|
||||
}),
|
||||
pinned: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Filter for pinned statuses only. Pinned statuses do not receive special priority in the order of the returned results.",
|
||||
}),
|
||||
tagged: z.string().optional().openapi({
|
||||
description:
|
||||
"Filter for statuses using a specific hashtag.",
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const {
|
||||
max_id,
|
||||
min_id,
|
||||
since_id,
|
||||
limit,
|
||||
exclude_reblogs,
|
||||
only_media,
|
||||
exclude_replies,
|
||||
pinned,
|
||||
} = context.req.valid("query");
|
||||
|
||||
const { objects } = await Timeline.getNoteTimeline(
|
||||
and(
|
||||
max_id ? lt(Notes.id, max_id) : undefined,
|
||||
since_id ? gte(Notes.id, since_id) : undefined,
|
||||
min_id ? gt(Notes.id, min_id) : undefined,
|
||||
eq(Notes.authorId, otherUser.id),
|
||||
only_media
|
||||
? sql`EXISTS (SELECT 1 FROM "Medias" WHERE "Medias"."noteId" = ${Notes.id})`
|
||||
: undefined,
|
||||
pinned
|
||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
|
||||
: undefined,
|
||||
// Visibility check
|
||||
or(
|
||||
sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${otherUser.id})`,
|
||||
and(
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
|
||||
inArray(Notes.visibility, ["public", "private"]),
|
||||
),
|
||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||
),
|
||||
exclude_reblogs ? isNull(Notes.reblogId) : undefined,
|
||||
exclude_replies ? isNull(Notes.replyId) : undefined,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
user?.id,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((note) => note.toApi(otherUser))),
|
||||
200,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
61
api/api/v1/accounts/[id]/unblock.ts
Normal file
61
api/api/v1/accounts/[id]/unblock.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/unblock",
|
||||
describeRoute({
|
||||
summary: "Unblock account",
|
||||
description: "Unblock the given account.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#unblock",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully unblocked, or account was already not blocked",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:blocks"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnBlocks,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (foundRelationship.data.blocking) {
|
||||
await foundRelationship.update({
|
||||
blocking: false,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
57
api/api/v1/accounts/[id]/unfollow.ts
Normal file
57
api/api/v1/accounts/[id]/unfollow.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/unfollow",
|
||||
describeRoute({
|
||||
summary: "Unfollow account",
|
||||
description: "Unfollow the given account.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#unfollow",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully unfollowed, or account was already not followed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:follows"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnFollows,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
await user.unfollow(otherUser, foundRelationship);
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
62
api/api/v1/accounts/[id]/unmute.ts
Normal file
62
api/api/v1/accounts/[id]/unmute.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/unmute",
|
||||
describeRoute({
|
||||
summary: "Unmute account",
|
||||
description: "Unmute the given account.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#unmute",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully unmuted, or account was already unmuted",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:mutes"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnMutes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (foundRelationship.data.muting) {
|
||||
await foundRelationship.update({
|
||||
muting: false,
|
||||
mutingNotifications: false,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
62
api/api/v1/accounts/[id]/unpin.ts
Normal file
62
api/api/v1/accounts/[id]/unpin.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { Relationship as RelationshipSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts/:id/unpin",
|
||||
describeRoute({
|
||||
summary: "Unfeature account from profile",
|
||||
description:
|
||||
"Remove the given account from the user’s featured profiles.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#unpin",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Successfully unendorsed, or account was already not endorsed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
withUserParam,
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:accounts"],
|
||||
permissions: [
|
||||
RolePermission.ManageOwnAccount,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
}),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const otherUser = context.get("user");
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (foundRelationship.data.endorsed) {
|
||||
await foundRelationship.update({
|
||||
endorsed: false,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { apiRoute, auth, qsQuery } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
FamiliarFollowers as FamiliarFollowersSchema,
|
||||
|
|
@ -8,71 +7,75 @@ import { RolePermission } from "@versia/client/schemas";
|
|||
import { User, db } from "@versia/kit/db";
|
||||
import type { Users } from "@versia/kit/tables";
|
||||
import { type InferSelectModel, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { rateLimit } from "~/middlewares/rate-limit";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/familiar_followers",
|
||||
summary: "Get familiar followers",
|
||||
description:
|
||||
"Obtain a list of all accounts that follow a given account, filtered for accounts you follow.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#familiar_followers",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/accounts/familiar_followers",
|
||||
describeRoute({
|
||||
summary: "Get familiar followers",
|
||||
description:
|
||||
"Obtain a list of all accounts that follow a given account, filtered for accounts you follow.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#familiar_followers",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Familiar followers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(FamiliarFollowersSchema)),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
qsQuery(),
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["read:follows"],
|
||||
permissions: [RolePermission.ManageOwnFollows],
|
||||
}),
|
||||
rateLimit(5),
|
||||
qsQuery(),
|
||||
] as const,
|
||||
request: {
|
||||
query: z.object({
|
||||
id: z
|
||||
.array(AccountSchema.shape.id)
|
||||
.min(1)
|
||||
.max(10)
|
||||
.or(AccountSchema.shape.id.transform((v) => [v]))
|
||||
.openapi({
|
||||
description:
|
||||
"Find familiar followers for the provided account IDs.",
|
||||
example: [
|
||||
"f137ce6f-ff5e-4998-b20f-0361ba9be007",
|
||||
"8424c654-5d03-4a1b-bec8-4e87db811b5d",
|
||||
],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Familiar followers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(FamiliarFollowersSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
id: z
|
||||
.array(AccountSchema.shape.id)
|
||||
.min(1)
|
||||
.max(10)
|
||||
.or(AccountSchema.shape.id.transform((v) => [v]))
|
||||
.openapi({
|
||||
description:
|
||||
"Find familiar followers for the provided account IDs.",
|
||||
example: [
|
||||
"f137ce6f-ff5e-4998-b20f-0361ba9be007",
|
||||
"8424c654-5d03-4a1b-bec8-4e87db811b5d",
|
||||
],
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { id: ids } = context.req.valid("query");
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { id: ids } = context.req.valid("query");
|
||||
|
||||
// Find followers of the accounts in "ids", that you also follow
|
||||
const finalUsers = await Promise.all(
|
||||
ids.map(async (id) => ({
|
||||
id,
|
||||
accounts: await User.fromIds(
|
||||
(
|
||||
await db.execute(sql<InferSelectModel<typeof Users>>`
|
||||
// Find followers of the accounts in "ids", that you also follow
|
||||
const finalUsers = await Promise.all(
|
||||
ids.map(async (id) => ({
|
||||
id,
|
||||
accounts: await User.fromIds(
|
||||
(
|
||||
await db.execute(sql<
|
||||
InferSelectModel<typeof Users>
|
||||
>`
|
||||
SELECT "Users"."id" FROM "Users"
|
||||
INNER JOIN "Relationships" AS "SelfFollowing"
|
||||
ON "SelfFollowing"."subjectId" = "Users"."id"
|
||||
|
|
@ -85,17 +88,18 @@ export default apiRoute((app) =>
|
|||
AND "IdsFollowers"."following" = true
|
||||
)
|
||||
`)
|
||||
).rows.map((u) => u.id as string),
|
||||
),
|
||||
})),
|
||||
);
|
||||
).rows.map((u) => u.id as string),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
finalUsers.map((u) => ({
|
||||
...u,
|
||||
accounts: u.accounts.map((a) => a.toApi()),
|
||||
})),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
return context.json(
|
||||
finalUsers.map((u) => ({
|
||||
...u,
|
||||
accounts: u.accounts.map((a) => a.toApi()),
|
||||
})),
|
||||
200,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { apiRoute, auth, jsonOrForm } from "@/api";
|
||||
import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
|
||||
import { tempmailDomains } from "@/tempmail";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { zBoolean } from "@versia/client/schemas";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import ISO6391 from "iso-639-1";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
import { rateLimit } from "~/middlewares/rate-limit";
|
||||
|
|
@ -40,17 +42,100 @@ const schema = z.object({
|
|||
}),
|
||||
});
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/accounts",
|
||||
summary: "Register an account",
|
||||
description:
|
||||
"Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.\n\nA relationship between the OAuth Application and created user account is stored.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#create",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/accounts",
|
||||
describeRoute({
|
||||
summary: "Register an account",
|
||||
description:
|
||||
"Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.\n\nA relationship between the OAuth Application and created user account is stored.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#create",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Token for the created account",
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: {
|
||||
description: "Validation failed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
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",
|
||||
"ERR_TOO_LONG",
|
||||
]),
|
||||
description: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: false,
|
||||
scopes: ["write:accounts"],
|
||||
|
|
@ -58,315 +143,223 @@ const route = createRoute({
|
|||
}),
|
||||
rateLimit(5),
|
||||
jsonOrForm(),
|
||||
] as const,
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema,
|
||||
},
|
||||
"multipart/form-data": {
|
||||
schema,
|
||||
},
|
||||
"application/x-www-form-urlencoded": {
|
||||
schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Token for the created account",
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
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",
|
||||
"ERR_TOO_LONG",
|
||||
]),
|
||||
description: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
validator("json", schema, handleZodError),
|
||||
async (context) => {
|
||||
const form = context.req.valid("json");
|
||||
const { username, email, password, agreement, locale } =
|
||||
context.req.valid("json");
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const form = context.req.valid("json");
|
||||
const { username, email, password, agreement, locale } =
|
||||
context.req.valid("json");
|
||||
if (!config.registration.allow) {
|
||||
throw new ApiError(422, "Registration is disabled");
|
||||
}
|
||||
|
||||
if (!config.registration.allow) {
|
||||
throw new ApiError(422, "Registration is disabled");
|
||||
}
|
||||
const errors: {
|
||||
details: Record<
|
||||
string,
|
||||
{
|
||||
error:
|
||||
| "ERR_BLANK"
|
||||
| "ERR_INVALID"
|
||||
| "ERR_TOO_LONG"
|
||||
| "ERR_TOO_SHORT"
|
||||
| "ERR_BLOCKED"
|
||||
| "ERR_TAKEN"
|
||||
| "ERR_RESERVED"
|
||||
| "ERR_ACCEPTED"
|
||||
| "ERR_INCLUSION";
|
||||
description: string;
|
||||
}[]
|
||||
>;
|
||||
} = {
|
||||
details: {
|
||||
password: [],
|
||||
username: [],
|
||||
email: [],
|
||||
agreement: [],
|
||||
locale: [],
|
||||
reason: [],
|
||||
},
|
||||
};
|
||||
|
||||
const errors: {
|
||||
details: Record<
|
||||
string,
|
||||
{
|
||||
error:
|
||||
| "ERR_BLANK"
|
||||
| "ERR_INVALID"
|
||||
| "ERR_TOO_LONG"
|
||||
| "ERR_TOO_SHORT"
|
||||
| "ERR_BLOCKED"
|
||||
| "ERR_TAKEN"
|
||||
| "ERR_RESERVED"
|
||||
| "ERR_ACCEPTED"
|
||||
| "ERR_INCLUSION";
|
||||
description: string;
|
||||
}[]
|
||||
>;
|
||||
} = {
|
||||
details: {
|
||||
password: [],
|
||||
username: [],
|
||||
email: [],
|
||||
agreement: [],
|
||||
locale: [],
|
||||
reason: [],
|
||||
},
|
||||
};
|
||||
// Check if fields are blank
|
||||
for (const value of [
|
||||
"username",
|
||||
"email",
|
||||
"password",
|
||||
"agreement",
|
||||
"locale",
|
||||
"reason",
|
||||
]) {
|
||||
// @ts-expect-error We don't care about the type here
|
||||
if (!form[value]) {
|
||||
errors.details[value].push({
|
||||
error: "ERR_BLANK",
|
||||
description: "can't be blank",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if fields are blank
|
||||
for (const value of [
|
||||
"username",
|
||||
"email",
|
||||
"password",
|
||||
"agreement",
|
||||
"locale",
|
||||
"reason",
|
||||
]) {
|
||||
// @ts-expect-error We don't care about the type here
|
||||
if (!form[value]) {
|
||||
errors.details[value].push({
|
||||
// Check if username is valid
|
||||
if (!username?.match(/^[a-z0-9_]+$/)) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_INVALID",
|
||||
description:
|
||||
"must only contain lowercase letters, numbers, and underscores",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username doesnt match filters
|
||||
if (
|
||||
config.validation.filters.username.some((filter) =>
|
||||
filter.test(username),
|
||||
)
|
||||
) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_INVALID",
|
||||
description: "contains blocked words",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username is too long
|
||||
if (
|
||||
(username?.length ?? 0) >
|
||||
config.validation.accounts.max_username_characters
|
||||
) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_TOO_LONG",
|
||||
description: `is too long (maximum is ${config.validation.accounts.max_username_characters} 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.accounts.disallowed_usernames.some((filter) =>
|
||||
filter.test(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.emails.disallowed_domains.some((f) =>
|
||||
f.test(email.split("@")[1]),
|
||||
) ||
|
||||
(config.validation.emails.disallow_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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if username is valid
|
||||
if (!username?.match(/^[a-z0-9_]+$/)) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_INVALID",
|
||||
description:
|
||||
"must only contain lowercase letters, numbers, and underscores",
|
||||
});
|
||||
}
|
||||
if (!ISO6391.validate(locale ?? "")) {
|
||||
errors.details.locale.push({
|
||||
error: "ERR_INVALID",
|
||||
description: "must be a valid ISO 639-1 code",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username doesnt match filters
|
||||
if (
|
||||
config.validation.filters.username.some((filter) =>
|
||||
filter.test(username),
|
||||
)
|
||||
) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_INVALID",
|
||||
description: "contains blocked words",
|
||||
});
|
||||
}
|
||||
// Check if reason is too long
|
||||
if ((form.reason?.length ?? 0) > 10_000) {
|
||||
errors.details.reason.push({
|
||||
error: "ERR_TOO_LONG",
|
||||
description: `is too long (maximum is ${10_000} characters)`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username is too long
|
||||
if (
|
||||
(username?.length ?? 0) >
|
||||
config.validation.accounts.max_username_characters
|
||||
) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_TOO_LONG",
|
||||
description: `is too long (maximum is ${config.validation.accounts.max_username_characters} characters)`,
|
||||
});
|
||||
}
|
||||
// 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"
|
||||
|
||||
// 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.accounts.disallowed_usernames.some((filter) =>
|
||||
filter.test(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.emails.disallowed_domains.some((f) =>
|
||||
f.test(email.split("@")[1]),
|
||||
) ||
|
||||
(config.validation.emails.disallow_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",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if reason is too long
|
||||
if ((form.reason?.length ?? 0) > 10_000) {
|
||||
errors.details.reason.push({
|
||||
error: "ERR_TOO_LONG",
|
||||
description: `is too long (maximum is ${10_000} characters)`,
|
||||
});
|
||||
}
|
||||
|
||||
// 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(", ");
|
||||
throw new ApiError(
|
||||
422,
|
||||
`Validation failed: ${errorsText}`,
|
||||
Object.fromEntries(
|
||||
Object.entries(errors.details).filter(
|
||||
([_, errors]) => errors.length > 0,
|
||||
const errorsText = Object.entries(errors.details)
|
||||
.filter(([_, errors]) => errors.length > 0)
|
||||
.map(
|
||||
([name, errors]) =>
|
||||
`${name} ${errors
|
||||
.map((error) => error.description)
|
||||
.join(", ")}`,
|
||||
)
|
||||
.join(", ");
|
||||
throw new ApiError(
|
||||
422,
|
||||
`Validation failed: ${errorsText}`,
|
||||
Object.fromEntries(
|
||||
Object.entries(errors.details).filter(
|
||||
([_, errors]) => errors.length > 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await User.fromDataLocal({
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
});
|
||||
await User.fromDataLocal({
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
});
|
||||
|
||||
return context.text("", 200);
|
||||
}),
|
||||
return context.text("", 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,108 +1,113 @@
|
|||
import { apiRoute, auth, parseUserAddress } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError, parseUserAddress } from "@/api";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Instance, User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
import { rateLimit } from "~/middlewares/rate-limit";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/lookup",
|
||||
summary: "Lookup account ID from Webfinger address",
|
||||
description:
|
||||
"Quickly lookup a username to see if it is available, skipping WebFinger resolution.",
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/accounts/lookup",
|
||||
describeRoute({
|
||||
summary: "Lookup account ID from Webfinger address",
|
||||
description:
|
||||
"Quickly lookup a username to see if it is available, skipping WebFinger resolution.",
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Account",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(AccountSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: false,
|
||||
permissions: [RolePermission.Search],
|
||||
}),
|
||||
rateLimit(5),
|
||||
] as const,
|
||||
request: {
|
||||
query: z.object({
|
||||
acct: AccountSchema.shape.acct.openapi({
|
||||
description: "The username or Webfinger address to lookup.",
|
||||
example: "lexi@beta.versia.social",
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
acct: AccountSchema.shape.acct.openapi({
|
||||
description: "The username or Webfinger address to lookup.",
|
||||
example: "lexi@beta.versia.social",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Account",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: AccountSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { acct } = context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
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 { username, domain } = parseUserAddress(acct);
|
||||
|
||||
// Check if acct is matching format username@domain.com or @username@domain.com
|
||||
const { username, domain } = parseUserAddress(acct);
|
||||
// User is local
|
||||
if (!domain || domain === config.http.base_url.host) {
|
||||
const account = await User.fromSql(
|
||||
and(eq(Users.username, username), isNull(Users.instanceId)),
|
||||
);
|
||||
|
||||
if (account) {
|
||||
return context.json(account.toApi(), 200);
|
||||
}
|
||||
|
||||
return context.json(
|
||||
{ error: `Account with username ${username} not found` },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
// User is remote
|
||||
// Try to fetch it from database
|
||||
const instance = await Instance.resolveFromHost(domain);
|
||||
|
||||
if (!instance) {
|
||||
return context.json(
|
||||
{ error: `Instance ${domain} not found` },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
// User is local
|
||||
if (!domain || domain === config.http.base_url.host) {
|
||||
const account = await User.fromSql(
|
||||
and(eq(Users.username, username), isNull(Users.instanceId)),
|
||||
and(
|
||||
eq(Users.username, username),
|
||||
eq(Users.instanceId, instance.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (account) {
|
||||
return context.json(account.toApi(), 200);
|
||||
}
|
||||
|
||||
return context.json(
|
||||
{ error: `Account with username ${username} not found` },
|
||||
404,
|
||||
);
|
||||
}
|
||||
// Fetch from remote instance
|
||||
const manager = await (user ?? User).getFederationRequester();
|
||||
|
||||
// User is remote
|
||||
// Try to fetch it from database
|
||||
const instance = await Instance.resolveFromHost(domain);
|
||||
const uri = await User.webFinger(manager, username, domain);
|
||||
|
||||
if (!instance) {
|
||||
return context.json({ error: `Instance ${domain} not found` }, 404);
|
||||
}
|
||||
if (!uri) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
const account = await User.fromSql(
|
||||
and(
|
||||
eq(Users.username, username),
|
||||
eq(Users.instanceId, instance.id),
|
||||
),
|
||||
);
|
||||
const foundAccount = await User.resolve(uri);
|
||||
|
||||
if (account) {
|
||||
return context.json(account.toApi(), 200);
|
||||
}
|
||||
if (foundAccount) {
|
||||
return context.json(foundAccount.toApi(), 200);
|
||||
}
|
||||
|
||||
// Fetch from remote instance
|
||||
const manager = await (user ?? User).getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(manager, username, domain);
|
||||
|
||||
if (!uri) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
const foundAccount = await User.resolve(uri);
|
||||
|
||||
if (foundAccount) {
|
||||
return context.json(foundAccount.toApi(), 200);
|
||||
}
|
||||
|
||||
throw ApiError.accountNotFound();
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { apiRoute, auth, qsQuery } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
|
|
@ -7,20 +6,36 @@ import {
|
|||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { rateLimit } from "~/middlewares/rate-limit";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/relationships",
|
||||
summary: "Check relationships to other accounts",
|
||||
description:
|
||||
"Find out whether a given account is followed, blocked, muted, etc.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#relationships",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/accounts/relationships",
|
||||
describeRoute({
|
||||
summary: "Check relationships to other accounts",
|
||||
description:
|
||||
"Find out whether a given account is followed, blocked, muted, etc.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#relationships",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Relationships",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(RelationshipSchema)),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
rateLimit(10),
|
||||
auth({
|
||||
auth: true,
|
||||
|
|
@ -28,65 +43,53 @@ const route = createRoute({
|
|||
permissions: [RolePermission.ManageOwnFollows],
|
||||
}),
|
||||
qsQuery(),
|
||||
] as const,
|
||||
request: {
|
||||
query: z.object({
|
||||
id: z
|
||||
.array(AccountSchema.shape.id)
|
||||
.min(1)
|
||||
.max(10)
|
||||
.or(AccountSchema.shape.id.transform((v) => [v]))
|
||||
.openapi({
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
id: z
|
||||
.array(AccountSchema.shape.id)
|
||||
.min(1)
|
||||
.max(10)
|
||||
.or(AccountSchema.shape.id.transform((v) => [v]))
|
||||
.openapi({
|
||||
description:
|
||||
"Check relationships for the provided account IDs.",
|
||||
example: [
|
||||
"f137ce6f-ff5e-4998-b20f-0361ba9be007",
|
||||
"8424c654-5d03-4a1b-bec8-4e87db811b5d",
|
||||
],
|
||||
}),
|
||||
with_suspended: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Check relationships for the provided account IDs.",
|
||||
example: [
|
||||
"f137ce6f-ff5e-4998-b20f-0361ba9be007",
|
||||
"8424c654-5d03-4a1b-bec8-4e87db811b5d",
|
||||
],
|
||||
"Whether relationships should be returned for suspended users",
|
||||
example: false,
|
||||
}),
|
||||
with_suspended: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Whether relationships should be returned for suspended users",
|
||||
example: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Relationships",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
// TODO: Implement with_suspended
|
||||
const { id } = context.req.valid("query");
|
||||
|
||||
const ids = Array.isArray(id) ? id : [id];
|
||||
|
||||
const relationships = await Relationship.fromOwnerAndSubjects(
|
||||
user,
|
||||
ids,
|
||||
);
|
||||
|
||||
relationships.sort(
|
||||
(a, b) =>
|
||||
ids.indexOf(a.data.subjectId) -
|
||||
ids.indexOf(b.data.subjectId),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
relationships.map((r) => r.toApi()),
|
||||
200,
|
||||
);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
// TODO: Implement with_suspended
|
||||
const { id } = context.req.valid("query");
|
||||
|
||||
const ids = Array.isArray(id) ? id : [id];
|
||||
|
||||
const relationships = await Relationship.fromOwnerAndSubjects(
|
||||
user,
|
||||
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,126 +1,138 @@
|
|||
import { apiRoute, auth, parseUserAddress } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError, parseUserAddress } from "@/api";
|
||||
import { Account as AccountSchema, zBoolean } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { eq, ilike, not, or, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import stringComparison from "string-comparison";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { rateLimit } from "~/middlewares/rate-limit";
|
||||
|
||||
export const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/search",
|
||||
summary: "Search for matching accounts",
|
||||
description: "Search for matching accounts by username or display name.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#search",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/accounts/search",
|
||||
describeRoute({
|
||||
summary: "Search for matching accounts",
|
||||
description:
|
||||
"Search for matching accounts by username or display name.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#search",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Accounts",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(AccountSchema)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
rateLimit(5),
|
||||
auth({
|
||||
auth: false,
|
||||
permissions: [RolePermission.Search, RolePermission.ViewAccounts],
|
||||
scopes: ["read:accounts"],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
query: z.object({
|
||||
q: AccountSchema.shape.username
|
||||
.or(AccountSchema.shape.acct)
|
||||
.openapi({
|
||||
description: "Search query for accounts.",
|
||||
example: "username",
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
q: AccountSchema.shape.username
|
||||
.or(AccountSchema.shape.acct)
|
||||
.openapi({
|
||||
description: "Search query for accounts.",
|
||||
example: "username",
|
||||
}),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.default(40)
|
||||
.openapi({
|
||||
description: "Maximum number of results.",
|
||||
example: 40,
|
||||
}),
|
||||
offset: z.coerce.number().int().default(0).openapi({
|
||||
description: "Skip the first n results.",
|
||||
example: 0,
|
||||
}),
|
||||
resolve: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Attempt WebFinger lookup. Use this when q is an exact address.",
|
||||
example: false,
|
||||
}),
|
||||
following: zBoolean.default(false).openapi({
|
||||
description: "Limit the search to users you are following.",
|
||||
example: false,
|
||||
}),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
|
||||
description: "Maximum number of results.",
|
||||
example: 40,
|
||||
}),
|
||||
offset: z.coerce.number().int().default(0).openapi({
|
||||
description: "Skip the first n results.",
|
||||
example: 0,
|
||||
}),
|
||||
resolve: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Attempt WebFinger lookup. Use this when q is an exact address.",
|
||||
example: false,
|
||||
}),
|
||||
following: zBoolean.default(false).openapi({
|
||||
description: "Limit the search to users you are following.",
|
||||
example: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Accounts",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(AccountSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { q, limit, offset, resolve, following } =
|
||||
context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { q, limit, offset, resolve, following } =
|
||||
context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
if (!user && following) {
|
||||
throw new ApiError(401, "Must be authenticated to use 'following'");
|
||||
}
|
||||
|
||||
const { username, domain } = parseUserAddress(q);
|
||||
|
||||
const accounts: User[] = [];
|
||||
|
||||
if (resolve && domain) {
|
||||
const manager = await (user ?? User).getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(manager, username, domain);
|
||||
|
||||
if (uri) {
|
||||
const resolvedUser = await User.resolve(uri);
|
||||
|
||||
if (resolvedUser) {
|
||||
accounts.push(resolvedUser);
|
||||
}
|
||||
if (!user && following) {
|
||||
throw new ApiError(
|
||||
401,
|
||||
"Must be authenticated to use 'following'",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
accounts.push(
|
||||
...(await User.manyFromSql(
|
||||
or(
|
||||
ilike(Users.displayName, `%${q}%`),
|
||||
ilike(Users.username, `%${q}%`),
|
||||
following && user
|
||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`
|
||||
: undefined,
|
||||
user ? not(eq(Users.id, user.id)) : undefined,
|
||||
),
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
)),
|
||||
|
||||
const { username, domain } = parseUserAddress(q);
|
||||
|
||||
const accounts: User[] = [];
|
||||
|
||||
if (resolve && domain) {
|
||||
const manager = await (user ?? User).getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(manager, username, domain);
|
||||
|
||||
if (uri) {
|
||||
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 && user
|
||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`
|
||||
: undefined,
|
||||
user ? not(eq(Users.id, user.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,
|
||||
);
|
||||
}
|
||||
|
||||
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,27 +1,42 @@
|
|||
import { apiRoute, auth, jsonOrForm } from "@/api";
|
||||
import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
|
||||
import { mergeAndDeduplicate } from "@/lib";
|
||||
import { sanitizedHtmlStrip } from "@/sanitization";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { Account as AccountSchema, zBoolean } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Emoji, Media, User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { contentToHtml } from "~/classes/functions/status";
|
||||
import { config } from "~/config.ts";
|
||||
import { rateLimit } from "~/middlewares/rate-limit";
|
||||
|
||||
const route = createRoute({
|
||||
method: "patch",
|
||||
path: "/api/v1/accounts/update_credentials",
|
||||
summary: "Update account credentials",
|
||||
description: "Update the user’s display and preferences.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#update_credentials",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.patch(
|
||||
"/api/v1/accounts/update_credentials",
|
||||
describeRoute({
|
||||
summary: "Update account credentials",
|
||||
description: "Update the user’s display and preferences.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#update_credentials",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated user",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(AccountSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
rateLimit(5),
|
||||
auth({
|
||||
auth: true,
|
||||
|
|
@ -29,341 +44,314 @@ const route = createRoute({
|
|||
scopes: ["write:accounts"],
|
||||
}),
|
||||
jsonOrForm(),
|
||||
] as const,
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z
|
||||
.object({
|
||||
display_name:
|
||||
AccountSchema.shape.display_name.openapi({
|
||||
description:
|
||||
"The display name to use for the profile.",
|
||||
example: "Lexi",
|
||||
}),
|
||||
username: AccountSchema.shape.username.openapi({
|
||||
description:
|
||||
"The username to use for the profile.",
|
||||
example: "lexi",
|
||||
}),
|
||||
note: AccountSchema.shape.note.openapi({
|
||||
description:
|
||||
"The account bio. Markdown is supported.",
|
||||
}),
|
||||
avatar: z
|
||||
.string()
|
||||
.url()
|
||||
.transform((a) => new URL(a))
|
||||
.openapi({
|
||||
description: "Avatar image URL",
|
||||
})
|
||||
.or(
|
||||
z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
config.validation.accounts
|
||||
.max_avatar_bytes,
|
||||
`Avatar must be less than ${config.validation.accounts.max_avatar_bytes} bytes`,
|
||||
)
|
||||
.openapi({
|
||||
description:
|
||||
"Avatar image encoded using multipart/form-data",
|
||||
}),
|
||||
),
|
||||
header: z
|
||||
.string()
|
||||
.url()
|
||||
.transform((v) => new URL(v))
|
||||
.openapi({
|
||||
description: "Header image URL",
|
||||
})
|
||||
.or(
|
||||
z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
config.validation.accounts
|
||||
.max_header_bytes,
|
||||
`Header must be less than ${config.validation.accounts.max_header_bytes} bytes`,
|
||||
)
|
||||
.openapi({
|
||||
description:
|
||||
"Header image encoded using multipart/form-data",
|
||||
}),
|
||||
),
|
||||
locked: AccountSchema.shape.locked.openapi({
|
||||
description:
|
||||
"Whether manual approval of follow requests is required.",
|
||||
}),
|
||||
bot: AccountSchema.shape.bot.openapi({
|
||||
description:
|
||||
"Whether the account has a bot flag.",
|
||||
}),
|
||||
discoverable:
|
||||
AccountSchema.shape.discoverable.openapi({
|
||||
description:
|
||||
"Whether the account should be shown in the profile directory.",
|
||||
}),
|
||||
// TODO: Implement :(
|
||||
hide_collections: zBoolean.openapi({
|
||||
description:
|
||||
"Whether to hide followers and followed accounts.",
|
||||
}),
|
||||
// TODO: Implement :(
|
||||
indexable: zBoolean.openapi({
|
||||
description:
|
||||
"Whether public posts should be searchable to anyone.",
|
||||
}),
|
||||
// TODO: Implement :(
|
||||
attribution_domains: z.array(z.string()).openapi({
|
||||
description:
|
||||
"Domains of websites allowed to credit the account.",
|
||||
example: ["cnn.com", "myblog.com"],
|
||||
}),
|
||||
source: z
|
||||
.object({
|
||||
privacy:
|
||||
AccountSchema.shape.source.unwrap()
|
||||
.shape.privacy,
|
||||
sensitive:
|
||||
AccountSchema.shape.source.unwrap()
|
||||
.shape.sensitive,
|
||||
language:
|
||||
AccountSchema.shape.source.unwrap()
|
||||
.shape.language,
|
||||
})
|
||||
.partial(),
|
||||
fields_attributes: z
|
||||
.array(
|
||||
z.object({
|
||||
name: AccountSchema.shape.fields.element
|
||||
.shape.name,
|
||||
value: AccountSchema.shape.fields
|
||||
.element.shape.value,
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z
|
||||
.object({
|
||||
display_name: AccountSchema.shape.display_name.openapi({
|
||||
description: "The display name to use for the profile.",
|
||||
example: "Lexi",
|
||||
}),
|
||||
username: AccountSchema.shape.username.openapi({
|
||||
description: "The username to use for the profile.",
|
||||
example: "lexi",
|
||||
}),
|
||||
note: AccountSchema.shape.note.openapi({
|
||||
description: "The account bio. Markdown is supported.",
|
||||
}),
|
||||
avatar: z
|
||||
.string()
|
||||
.url()
|
||||
.transform((a) => new URL(a))
|
||||
.openapi({
|
||||
description: "Avatar image URL",
|
||||
})
|
||||
.or(
|
||||
z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
config.validation.accounts
|
||||
.max_avatar_bytes,
|
||||
`Avatar must be less than ${config.validation.accounts.max_avatar_bytes} bytes`,
|
||||
)
|
||||
.max(
|
||||
config.validation.accounts.max_field_count,
|
||||
),
|
||||
.openapi({
|
||||
description:
|
||||
"Avatar image encoded using multipart/form-data",
|
||||
}),
|
||||
),
|
||||
header: z
|
||||
.string()
|
||||
.url()
|
||||
.transform((v) => new URL(v))
|
||||
.openapi({
|
||||
description: "Header image URL",
|
||||
})
|
||||
.or(
|
||||
z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
config.validation.accounts
|
||||
.max_header_bytes,
|
||||
`Header must be less than ${config.validation.accounts.max_header_bytes} bytes`,
|
||||
)
|
||||
.openapi({
|
||||
description:
|
||||
"Header image encoded using multipart/form-data",
|
||||
}),
|
||||
),
|
||||
locked: AccountSchema.shape.locked.openapi({
|
||||
description:
|
||||
"Whether manual approval of follow requests is required.",
|
||||
}),
|
||||
bot: AccountSchema.shape.bot.openapi({
|
||||
description: "Whether the account has a bot flag.",
|
||||
}),
|
||||
discoverable: AccountSchema.shape.discoverable.openapi({
|
||||
description:
|
||||
"Whether the account should be shown in the profile directory.",
|
||||
}),
|
||||
// TODO: Implement :(
|
||||
hide_collections: zBoolean.openapi({
|
||||
description:
|
||||
"Whether to hide followers and followed accounts.",
|
||||
}),
|
||||
// TODO: Implement :(
|
||||
indexable: zBoolean.openapi({
|
||||
description:
|
||||
"Whether public posts should be searchable to anyone.",
|
||||
}),
|
||||
// TODO: Implement :(
|
||||
attribution_domains: z.array(z.string()).openapi({
|
||||
description:
|
||||
"Domains of websites allowed to credit the account.",
|
||||
example: ["cnn.com", "myblog.com"],
|
||||
}),
|
||||
source: z
|
||||
.object({
|
||||
privacy:
|
||||
AccountSchema.shape.source.unwrap().shape
|
||||
.privacy,
|
||||
sensitive:
|
||||
AccountSchema.shape.source.unwrap().shape
|
||||
.sensitive,
|
||||
language:
|
||||
AccountSchema.shape.source.unwrap().shape
|
||||
.language,
|
||||
})
|
||||
.partial(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated user",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: AccountSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
fields_attributes: z
|
||||
.array(
|
||||
z.object({
|
||||
name: AccountSchema.shape.fields.element.shape
|
||||
.name,
|
||||
value: AccountSchema.shape.fields.element.shape
|
||||
.value,
|
||||
}),
|
||||
)
|
||||
.max(config.validation.accounts.max_field_count),
|
||||
})
|
||||
.partial(),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const {
|
||||
display_name,
|
||||
username,
|
||||
note,
|
||||
avatar,
|
||||
header,
|
||||
locked,
|
||||
bot,
|
||||
discoverable,
|
||||
source,
|
||||
fields_attributes,
|
||||
} = context.req.valid("json");
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const {
|
||||
display_name,
|
||||
username,
|
||||
note,
|
||||
avatar,
|
||||
header,
|
||||
locked,
|
||||
bot,
|
||||
discoverable,
|
||||
source,
|
||||
fields_attributes,
|
||||
} = context.req.valid("json");
|
||||
const self = user.data;
|
||||
|
||||
const self = user.data;
|
||||
|
||||
const sanitizedDisplayName = await sanitizedHtmlStrip(
|
||||
display_name ?? "",
|
||||
);
|
||||
|
||||
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 sanitizedDisplayName = await sanitizedHtmlStrip(
|
||||
display_name ?? "",
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
throw new ApiError(422, "Username is already taken");
|
||||
if (display_name) {
|
||||
self.displayName = sanitizedDisplayName;
|
||||
}
|
||||
|
||||
self.username = username;
|
||||
}
|
||||
|
||||
if (avatar) {
|
||||
if (avatar instanceof File) {
|
||||
if (user.avatar) {
|
||||
await user.avatar.updateFromFile(avatar);
|
||||
} else {
|
||||
user.avatar = await Media.fromFile(avatar);
|
||||
}
|
||||
} else if (user.avatar) {
|
||||
await user.avatar.updateFromUrl(avatar);
|
||||
} else {
|
||||
user.avatar = await Media.fromUrl(avatar);
|
||||
}
|
||||
}
|
||||
|
||||
if (header) {
|
||||
if (header instanceof File) {
|
||||
if (user.header) {
|
||||
await user.header.updateFromFile(header);
|
||||
} else {
|
||||
user.header = await Media.fromFile(header);
|
||||
}
|
||||
} else if (user.header) {
|
||||
await user.header.updateFromUrl(header);
|
||||
} else {
|
||||
user.header = await Media.fromUrl(header);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const parsedValue = await contentToHtml(
|
||||
{
|
||||
"text/markdown": {
|
||||
content: field.value,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
// Parse emojis
|
||||
const nameEmojis = await Emoji.parseFromText(parsedName);
|
||||
const valueEmojis = await Emoji.parseFromText(parsedValue);
|
||||
|
||||
fieldEmojis.push(...nameEmojis, ...valueEmojis);
|
||||
|
||||
// Replace fields
|
||||
self.fields.push({
|
||||
key: {
|
||||
"text/html": {
|
||||
content: parsedName,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
value: {
|
||||
"text/html": {
|
||||
content: parsedValue,
|
||||
remote: false,
|
||||
},
|
||||
if (note && self.source) {
|
||||
self.source.note = note;
|
||||
self.note = await contentToHtml({
|
||||
"text/markdown": {
|
||||
content: note,
|
||||
remote: false,
|
||||
},
|
||||
});
|
||||
|
||||
self.source.fields.push({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
verified_at: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse emojis
|
||||
const displaynameEmojis =
|
||||
await Emoji.parseFromText(sanitizedDisplayName);
|
||||
const noteEmojis = await Emoji.parseFromText(self.note);
|
||||
if (source?.privacy) {
|
||||
self.source.privacy = source.privacy;
|
||||
}
|
||||
|
||||
const emojis = mergeAndDeduplicate(
|
||||
displaynameEmojis,
|
||||
noteEmojis,
|
||||
fieldEmojis,
|
||||
);
|
||||
if (source?.sensitive) {
|
||||
self.source.sensitive = source.sensitive;
|
||||
}
|
||||
|
||||
// Connect emojis, if any
|
||||
// Do it before updating user, so that federation takes that into account
|
||||
await user.updateEmojis(emojis);
|
||||
await user.update({
|
||||
displayName: self.displayName,
|
||||
username: self.username,
|
||||
note: self.note,
|
||||
avatar: self.avatar,
|
||||
avatarId: user.avatar?.id,
|
||||
header: self.header,
|
||||
headerId: user.header?.id,
|
||||
fields: self.fields,
|
||||
isLocked: self.isLocked,
|
||||
isBot: self.isBot,
|
||||
isDiscoverable: self.isDiscoverable,
|
||||
source: self.source || undefined,
|
||||
});
|
||||
if (source?.language) {
|
||||
self.source.language = source.language;
|
||||
}
|
||||
|
||||
const output = await User.fromId(self.id);
|
||||
if (username) {
|
||||
// Check if username is already taken
|
||||
const existingUser = await User.fromSql(
|
||||
and(isNull(Users.instanceId), eq(Users.username, username)),
|
||||
);
|
||||
|
||||
if (!output) {
|
||||
throw new ApiError(500, "Couldn't edit user");
|
||||
}
|
||||
if (existingUser) {
|
||||
throw new ApiError(422, "Username is already taken");
|
||||
}
|
||||
|
||||
return context.json(output.toApi(), 200);
|
||||
}),
|
||||
self.username = username;
|
||||
}
|
||||
|
||||
if (avatar) {
|
||||
if (avatar instanceof File) {
|
||||
if (user.avatar) {
|
||||
await user.avatar.updateFromFile(avatar);
|
||||
} else {
|
||||
user.avatar = await Media.fromFile(avatar);
|
||||
}
|
||||
} else if (user.avatar) {
|
||||
await user.avatar.updateFromUrl(avatar);
|
||||
} else {
|
||||
user.avatar = await Media.fromUrl(avatar);
|
||||
}
|
||||
}
|
||||
|
||||
if (header) {
|
||||
if (header instanceof File) {
|
||||
if (user.header) {
|
||||
await user.header.updateFromFile(header);
|
||||
} else {
|
||||
user.header = await Media.fromFile(header);
|
||||
}
|
||||
} else if (user.header) {
|
||||
await user.header.updateFromUrl(header);
|
||||
} else {
|
||||
user.header = await Media.fromUrl(header);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const parsedValue = await contentToHtml(
|
||||
{
|
||||
"text/markdown": {
|
||||
content: field.value,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
// Parse emojis
|
||||
const nameEmojis = await Emoji.parseFromText(parsedName);
|
||||
const valueEmojis = await Emoji.parseFromText(parsedValue);
|
||||
|
||||
fieldEmojis.push(...nameEmojis, ...valueEmojis);
|
||||
|
||||
// Replace fields
|
||||
self.fields.push({
|
||||
key: {
|
||||
"text/html": {
|
||||
content: parsedName,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
value: {
|
||||
"text/html": {
|
||||
content: parsedValue,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
self.source.fields.push({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
verified_at: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse emojis
|
||||
const displaynameEmojis =
|
||||
await Emoji.parseFromText(sanitizedDisplayName);
|
||||
const noteEmojis = await Emoji.parseFromText(self.note);
|
||||
|
||||
const emojis = mergeAndDeduplicate(
|
||||
displaynameEmojis,
|
||||
noteEmojis,
|
||||
fieldEmojis,
|
||||
);
|
||||
|
||||
// Connect emojis, if any
|
||||
// Do it before updating user, so that federation takes that into account
|
||||
await user.updateEmojis(emojis);
|
||||
await user.update({
|
||||
displayName: self.displayName,
|
||||
username: self.username,
|
||||
note: self.note,
|
||||
avatar: self.avatar,
|
||||
avatarId: user.avatar?.id,
|
||||
header: self.header,
|
||||
headerId: user.header?.id,
|
||||
fields: self.fields,
|
||||
isLocked: self.isLocked,
|
||||
isBot: self.isBot,
|
||||
isDiscoverable: self.isDiscoverable,
|
||||
source: self.source || undefined,
|
||||
});
|
||||
|
||||
const output = await User.fromId(self.id);
|
||||
|
||||
if (!output) {
|
||||
throw new ApiError(500, "Couldn't edit user");
|
||||
}
|
||||
|
||||
return context.json(output.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,44 +1,43 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { Account } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/accounts/verify_credentials",
|
||||
summary: "Verify account credentials",
|
||||
description: "Test to make sure that the user token works.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#verify_credentials",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/accounts/verify_credentials",
|
||||
describeRoute({
|
||||
summary: "Verify account credentials",
|
||||
description: "Test to make sure that the user token works.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/accounts/#verify_credentials",
|
||||
},
|
||||
tags: ["Accounts"],
|
||||
responses: {
|
||||
200: {
|
||||
// TODO: Implement CredentialAccount
|
||||
description:
|
||||
"Note the extra source property, which is not visible on accounts other than your own. Also note that plain-text is used within source and HTML is used for their corresponding properties such as note and fields.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Account),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["read:accounts"],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
// TODO: Implement CredentialAccount
|
||||
description:
|
||||
"Note the extra source property, which is not visible on accounts other than your own. Also note that plain-text is used within source and HTML is used for their corresponding properties such as note and fields.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Account,
|
||||
},
|
||||
},
|
||||
(context) => {
|
||||
// TODO: Add checks for disabled/unverified accounts
|
||||
const { user } = context.get("auth");
|
||||
|
||||
return context.json(user.toApi(true), 200);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, (context) => {
|
||||
// TODO: Add checks for disabled/unverified accounts
|
||||
const { user } = context.get("auth");
|
||||
|
||||
return context.json(user.toApi(true), 200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,80 +1,80 @@
|
|||
import { apiRoute, jsonOrForm } from "@/api";
|
||||
import { apiRoute, handleZodError, jsonOrForm } from "@/api";
|
||||
import { randomString } from "@/math";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Application as ApplicationSchema,
|
||||
CredentialApplication as CredentialApplicationSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { Application } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { rateLimit } from "~/middlewares/rate-limit";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/apps",
|
||||
summary: "Create an application",
|
||||
description: "Create a new application to obtain OAuth2 credentials.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/apps/#create",
|
||||
},
|
||||
tags: ["Apps"],
|
||||
middleware: [jsonOrForm(), rateLimit(4)],
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
client_name: ApplicationSchema.shape.name,
|
||||
redirect_uris: ApplicationSchema.shape.redirect_uris.or(
|
||||
ApplicationSchema.shape.redirect_uri.transform(
|
||||
(u) => u.split("\n"),
|
||||
),
|
||||
),
|
||||
scopes: z
|
||||
.string()
|
||||
.default("read")
|
||||
.transform((s) => s.split(" "))
|
||||
.openapi({
|
||||
description: "Space separated list of scopes.",
|
||||
}),
|
||||
// Allow empty websites because Traewelling decides to give an empty
|
||||
// value instead of not providing anything at all
|
||||
website: ApplicationSchema.shape.website
|
||||
.optional()
|
||||
.or(z.literal("").transform(() => undefined)),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Store the client_id and client_secret in your cache, as these will be used to obtain OAuth tokens.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: CredentialApplicationSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { client_name, redirect_uris, scopes, website } =
|
||||
context.req.valid("json");
|
||||
app.post(
|
||||
"/api/v1/apps",
|
||||
describeRoute({
|
||||
summary: "Create an application",
|
||||
description:
|
||||
"Create a new application to obtain OAuth2 credentials.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/apps/#create",
|
||||
},
|
||||
tags: ["Apps"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Store the client_id and client_secret in your cache, as these will be used to obtain OAuth tokens.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(CredentialApplicationSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
jsonOrForm(),
|
||||
rateLimit(4),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
client_name: ApplicationSchema.shape.name,
|
||||
redirect_uris: ApplicationSchema.shape.redirect_uris.or(
|
||||
ApplicationSchema.shape.redirect_uri.transform((u) =>
|
||||
u.split("\n"),
|
||||
),
|
||||
),
|
||||
scopes: z
|
||||
.string()
|
||||
.default("read")
|
||||
.transform((s) => s.split(" "))
|
||||
.openapi({
|
||||
description: "Space separated list of scopes.",
|
||||
}),
|
||||
// Allow empty websites because Traewelling decides to give an empty
|
||||
// value instead of not providing anything at all
|
||||
website: ApplicationSchema.shape.website
|
||||
.optional()
|
||||
.or(z.literal("").transform(() => undefined)),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { client_name, redirect_uris, scopes, website } =
|
||||
context.req.valid("json");
|
||||
|
||||
const app = await Application.insert({
|
||||
name: client_name,
|
||||
redirectUri: redirect_uris.join("\n"),
|
||||
scopes: scopes.join(" "),
|
||||
website,
|
||||
clientId: randomString(32, "base64url"),
|
||||
secret: randomString(64, "base64url"),
|
||||
});
|
||||
const app = await Application.insert({
|
||||
name: client_name,
|
||||
redirectUri: redirect_uris.join("\n"),
|
||||
scopes: scopes.join(" "),
|
||||
website,
|
||||
clientId: randomString(32, "base64url"),
|
||||
secret: randomString(64, "base64url"),
|
||||
});
|
||||
|
||||
return context.json(app.toApiCredential(), 200);
|
||||
}),
|
||||
return context.json(app.toApiCredential(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,52 +1,51 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { Application as ApplicationSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Application } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/apps/verify_credentials",
|
||||
summary: "Verify your app works",
|
||||
description: "Confirm that the app’s OAuth2 credentials work.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/apps/#verify_credentials",
|
||||
},
|
||||
tags: ["Apps"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/apps/verify_credentials",
|
||||
describeRoute({
|
||||
summary: "Verify your app works",
|
||||
description: "Confirm that the app’s OAuth2 credentials work.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/apps/#verify_credentials",
|
||||
},
|
||||
tags: ["Apps"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApplicationSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnApps],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"If the Authorization header was provided with a valid token, you should see your app returned as an Application entity.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApplicationSchema,
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { token } = context.get("auth");
|
||||
|
||||
const application = await Application.getFromToken(
|
||||
token.data.accessToken,
|
||||
);
|
||||
|
||||
if (!application) {
|
||||
throw ApiError.applicationNotFound();
|
||||
}
|
||||
|
||||
return context.json(application.toApi(), 200);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { token } = context.get("auth");
|
||||
|
||||
const application = await Application.getFromToken(
|
||||
token.data.accessToken,
|
||||
);
|
||||
|
||||
if (!application) {
|
||||
throw ApiError.applicationNotFound();
|
||||
}
|
||||
|
||||
return context.json(application.toApi(), 200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,100 +1,110 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/blocks",
|
||||
summary: "View your blocks.",
|
||||
description: "View blocked users.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/blocks/#get",
|
||||
},
|
||||
tags: ["Blocks"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/blocks",
|
||||
describeRoute({
|
||||
summary: "View your blocks.",
|
||||
description: "View blocked users.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/blocks/#get",
|
||||
},
|
||||
tags: ["Blocks"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of blocked users",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(AccountSchema)),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/blocks?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/blocks?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["read:blocks"],
|
||||
permissions: [RolePermission.ManageOwnBlocks],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
query: z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of blocked users",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(AccountSchema),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.default(40)
|
||||
.openapi({
|
||||
description: "Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/blocks?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/blocks?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { max_id, since_id, min_id, limit } =
|
||||
context.req.valid("query");
|
||||
|
||||
const { user } = context.get("auth");
|
||||
|
||||
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,
|
||||
new URL(context.req.url),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
blocks.map((u) => u.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
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,
|
||||
new URL(context.req.url),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
blocks.map((u) => u.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,55 +1,54 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { generateChallenge } from "@/challenges";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { Challenge } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/challenges",
|
||||
summary: "Generate a challenge",
|
||||
description: "Generate a challenge to solve",
|
||||
tags: ["Challenges"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/challenges",
|
||||
describeRoute({
|
||||
summary: "Generate a challenge",
|
||||
description: "Generate a challenge to solve",
|
||||
tags: ["Challenges"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Challenge",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Challenge),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Challenges are disabled",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: false,
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description: "Challenge",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Challenge,
|
||||
async (context) => {
|
||||
if (!config.validation.challenges) {
|
||||
throw new ApiError(400, "Challenges are disabled in config");
|
||||
}
|
||||
|
||||
const result = await generateChallenge();
|
||||
|
||||
return context.json(
|
||||
{
|
||||
id: result.id,
|
||||
...result.challenge,
|
||||
},
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
400: {
|
||||
description: "Challenges are disabled",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
if (!config.validation.challenges) {
|
||||
throw new ApiError(400, "Challenges are disabled in config");
|
||||
}
|
||||
|
||||
const result = await generateChallenge();
|
||||
|
||||
return context.json(
|
||||
{
|
||||
id: result.id,
|
||||
...result.challenge,
|
||||
},
|
||||
200,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,57 +1,58 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Emoji } from "@versia/kit/db";
|
||||
import { Emojis } from "@versia/kit/tables";
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/custom_emojis",
|
||||
summary: "View all custom emoji",
|
||||
description: "Returns custom emojis that are available on the server.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/custom_emojis/#get",
|
||||
},
|
||||
tags: ["Emojis"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/custom_emojis",
|
||||
describeRoute({
|
||||
summary: "View all custom emoji",
|
||||
description:
|
||||
"Returns custom emojis that are available on the server.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/custom_emojis/#get",
|
||||
},
|
||||
tags: ["Emojis"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of custom emojis",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(CustomEmojiSchema)),
|
||||
},
|
||||
},
|
||||
},
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: false,
|
||||
permissions: [RolePermission.ViewEmojis],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of custom emojis",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(CustomEmojiSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
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,
|
||||
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,
|
||||
);
|
||||
}),
|
||||
return context.json(
|
||||
emojis.map((emoji) => emoji.toApi()),
|
||||
200,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,276 +0,0 @@
|
|||
import { apiRoute, auth, jsonOrForm, withEmojiParam } from "@/api";
|
||||
import { mimeLookup } from "@/content_types";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
shortcode: CustomEmojiSchema.shape.shortcode,
|
||||
element: z
|
||||
.string()
|
||||
.url()
|
||||
.transform((a) => new URL(a))
|
||||
.openapi({
|
||||
description: "Emoji image URL",
|
||||
})
|
||||
.or(
|
||||
z
|
||||
.instanceof(File)
|
||||
.openapi({
|
||||
description:
|
||||
"Emoji image encoded using multipart/form-data",
|
||||
})
|
||||
.refine(
|
||||
(v) => v.size <= config.validation.emojis.max_bytes,
|
||||
`Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
|
||||
),
|
||||
),
|
||||
category: CustomEmojiSchema.shape.category.optional(),
|
||||
alt: CustomEmojiSchema.shape.description.optional(),
|
||||
global: CustomEmojiSchema.shape.global.default(false),
|
||||
})
|
||||
.partial();
|
||||
|
||||
const routeGet = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/emojis/{id}",
|
||||
summary: "Get emoji",
|
||||
description: "Retrieves a custom emoji from database by ID.",
|
||||
tags: ["Emojis"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ViewEmojis],
|
||||
}),
|
||||
withEmojiParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: CustomEmojiSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Emoji",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: CustomEmojiSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Emoji not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
const routePatch = createRoute({
|
||||
method: "patch",
|
||||
path: "/api/v1/emojis/{id}",
|
||||
summary: "Modify emoji",
|
||||
description: "Edit image or metadata of an emoji.",
|
||||
tags: ["Emojis"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [
|
||||
RolePermission.ManageOwnEmojis,
|
||||
RolePermission.ViewEmojis,
|
||||
],
|
||||
}),
|
||||
jsonOrForm(),
|
||||
withEmojiParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: CustomEmojiSchema.shape.id,
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema,
|
||||
},
|
||||
"application/x-www-form-urlencoded": {
|
||||
schema,
|
||||
},
|
||||
"multipart/form-data": {
|
||||
schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Emoji modified",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: CustomEmojiSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Insufficient permissions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Emoji not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
const routeDelete = createRoute({
|
||||
method: "delete",
|
||||
path: "/api/v1/emojis/{id}",
|
||||
summary: "Delete emoji",
|
||||
description: "Delete a custom emoji from the database.",
|
||||
tags: ["Emojis"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [
|
||||
RolePermission.ManageOwnEmojis,
|
||||
RolePermission.ViewEmojis,
|
||||
],
|
||||
}),
|
||||
withEmojiParam,
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: CustomEmojiSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: "Emoji deleted",
|
||||
},
|
||||
404: ApiError.emojiNotFound().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.openapi(routeGet, (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const emoji = context.get("emoji");
|
||||
|
||||
// Don't leak non-global emojis to non-admins
|
||||
if (
|
||||
!user.hasPermission(RolePermission.ManageEmojis) &&
|
||||
emoji.data.ownerId !== user.data.id
|
||||
) {
|
||||
throw ApiError.emojiNotFound();
|
||||
}
|
||||
|
||||
return context.json(emoji.toApi(), 200);
|
||||
});
|
||||
|
||||
app.openapi(routePatch, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const emoji = context.get("emoji");
|
||||
|
||||
// Check if user is admin
|
||||
if (
|
||||
!user.hasPermission(RolePermission.ManageEmojis) &&
|
||||
emoji.data.ownerId !== user.data.id
|
||||
) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
"Cannot modify emoji not owned by you",
|
||||
`This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
global: emojiGlobal,
|
||||
alt,
|
||||
category,
|
||||
element,
|
||||
shortcode,
|
||||
} = context.req.valid("json");
|
||||
|
||||
if (!user.hasPermission(RolePermission.ManageEmojis) && emojiGlobal) {
|
||||
throw new ApiError(
|
||||
401,
|
||||
"Missing permissions",
|
||||
`'${RolePermission.ManageEmojis}' permission is needed to upload global emojis`,
|
||||
);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
// Check if emoji is an image
|
||||
const contentType =
|
||||
element instanceof File
|
||||
? element.type
|
||||
: await mimeLookup(element);
|
||||
|
||||
if (!contentType.startsWith("image/")) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
"Invalid content type",
|
||||
`Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (element instanceof File) {
|
||||
await emoji.media.updateFromFile(element);
|
||||
} else {
|
||||
await emoji.media.updateFromUrl(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (alt) {
|
||||
await emoji.media.updateMetadata({
|
||||
description: alt,
|
||||
});
|
||||
}
|
||||
|
||||
await emoji.update({
|
||||
shortcode,
|
||||
ownerId: emojiGlobal ? null : user.data.id,
|
||||
category,
|
||||
});
|
||||
|
||||
return context.json(emoji.toApi(), 200);
|
||||
});
|
||||
|
||||
app.openapi(routeDelete, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const emoji = context.get("emoji");
|
||||
|
||||
// Check if user is admin
|
||||
if (
|
||||
!user.hasPermission(RolePermission.ManageEmojis) &&
|
||||
emoji.data.ownerId !== user.data.id
|
||||
) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
"Cannot delete emoji not owned by you",
|
||||
`This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
|
||||
);
|
||||
}
|
||||
|
||||
await emoji.delete();
|
||||
|
||||
return context.body(null, 204);
|
||||
});
|
||||
});
|
||||
258
api/api/v1/emojis/[id]/index.ts
Normal file
258
api/api/v1/emojis/[id]/index.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import {
|
||||
apiRoute,
|
||||
auth,
|
||||
handleZodError,
|
||||
jsonOrForm,
|
||||
withEmojiParam,
|
||||
} from "@/api";
|
||||
import { mimeLookup } from "@/content_types";
|
||||
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.get(
|
||||
"/api/v1/emojis/:id",
|
||||
describeRoute({
|
||||
summary: "Get emoji",
|
||||
description: "Retrieves a custom emoji from database by ID.",
|
||||
tags: ["Emojis"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Emoji",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(CustomEmojiSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Emoji not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ViewEmojis],
|
||||
}),
|
||||
withEmojiParam,
|
||||
(context) => {
|
||||
const { user } = context.get("auth");
|
||||
const emoji = context.get("emoji");
|
||||
|
||||
// Don't leak non-global emojis to non-admins
|
||||
if (
|
||||
!user.hasPermission(RolePermission.ManageEmojis) &&
|
||||
emoji.data.ownerId !== user.data.id
|
||||
) {
|
||||
throw ApiError.emojiNotFound();
|
||||
}
|
||||
|
||||
return context.json(emoji.toApi(), 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/api/v1/emojis/:id",
|
||||
describeRoute({
|
||||
summary: "Modify emoji",
|
||||
description: "Edit image or metadata of an emoji.",
|
||||
tags: ["Emojis"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Emoji modified",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(CustomEmojiSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Insufficient permissions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Emoji not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [
|
||||
RolePermission.ManageOwnEmojis,
|
||||
RolePermission.ViewEmojis,
|
||||
],
|
||||
}),
|
||||
jsonOrForm(),
|
||||
withEmojiParam,
|
||||
validator(
|
||||
"json",
|
||||
z
|
||||
.object({
|
||||
shortcode: CustomEmojiSchema.shape.shortcode,
|
||||
element: z
|
||||
.string()
|
||||
.url()
|
||||
.transform((a) => new URL(a))
|
||||
.openapi({
|
||||
description: "Emoji image URL",
|
||||
})
|
||||
.or(
|
||||
z
|
||||
.instanceof(File)
|
||||
.openapi({
|
||||
description:
|
||||
"Emoji image encoded using multipart/form-data",
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
config.validation.emojis.max_bytes,
|
||||
`Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
|
||||
),
|
||||
),
|
||||
category: CustomEmojiSchema.shape.category.optional(),
|
||||
alt: CustomEmojiSchema.shape.description.optional(),
|
||||
global: CustomEmojiSchema.shape.global.default(false),
|
||||
})
|
||||
.partial(),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const emoji = context.get("emoji");
|
||||
|
||||
// Check if user is admin
|
||||
if (
|
||||
!user.hasPermission(RolePermission.ManageEmojis) &&
|
||||
emoji.data.ownerId !== user.data.id
|
||||
) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
"Cannot modify emoji not owned by you",
|
||||
`This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
global: emojiGlobal,
|
||||
alt,
|
||||
category,
|
||||
element,
|
||||
shortcode,
|
||||
} = context.req.valid("json");
|
||||
|
||||
if (
|
||||
!user.hasPermission(RolePermission.ManageEmojis) &&
|
||||
emojiGlobal
|
||||
) {
|
||||
throw new ApiError(
|
||||
401,
|
||||
"Missing permissions",
|
||||
`'${RolePermission.ManageEmojis}' permission is needed to upload global emojis`,
|
||||
);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
// Check if emoji is an image
|
||||
const contentType =
|
||||
element instanceof File
|
||||
? element.type
|
||||
: await mimeLookup(element);
|
||||
|
||||
if (!contentType.startsWith("image/")) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
"Invalid content type",
|
||||
`Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (element instanceof File) {
|
||||
await emoji.media.updateFromFile(element);
|
||||
} else {
|
||||
await emoji.media.updateFromUrl(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (alt) {
|
||||
await emoji.media.updateMetadata({
|
||||
description: alt,
|
||||
});
|
||||
}
|
||||
|
||||
await emoji.update({
|
||||
shortcode,
|
||||
ownerId: emojiGlobal ? null : user.data.id,
|
||||
category,
|
||||
});
|
||||
|
||||
return context.json(emoji.toApi(), 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/api/v1/emojis/{id}",
|
||||
describeRoute({
|
||||
summary: "Delete emoji",
|
||||
description: "Delete a custom emoji from the database.",
|
||||
tags: ["Emojis"],
|
||||
responses: {
|
||||
204: {
|
||||
description: "Emoji deleted",
|
||||
},
|
||||
404: ApiError.emojiNotFound().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [
|
||||
RolePermission.ManageOwnEmojis,
|
||||
RolePermission.ViewEmojis,
|
||||
],
|
||||
}),
|
||||
withEmojiParam,
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const emoji = context.get("emoji");
|
||||
|
||||
// Check if user is admin
|
||||
if (
|
||||
!user.hasPermission(RolePermission.ManageEmojis) &&
|
||||
emoji.data.ownerId !== user.data.id
|
||||
) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
"Cannot delete emoji not owned by you",
|
||||
`This emoji is either global (and you do not have the '${RolePermission.ManageEmojis}' permission) or not owned by you`,
|
||||
);
|
||||
}
|
||||
|
||||
await emoji.delete();
|
||||
|
||||
return context.body(null, 204);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,47 +1,36 @@
|
|||
import { apiRoute, auth, jsonOrForm } from "@/api";
|
||||
import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
|
||||
import { mimeLookup } from "@/content_types";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { CustomEmoji as CustomEmojiSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Emoji, Media } from "@versia/kit/db";
|
||||
import { Emojis } from "@versia/kit/tables";
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const schema = z.object({
|
||||
shortcode: CustomEmojiSchema.shape.shortcode,
|
||||
element: z
|
||||
.string()
|
||||
.url()
|
||||
.transform((a) => new URL(a))
|
||||
.openapi({
|
||||
description: "Emoji image URL",
|
||||
})
|
||||
.or(
|
||||
z
|
||||
.instanceof(File)
|
||||
.openapi({
|
||||
description:
|
||||
"Emoji image encoded using multipart/form-data",
|
||||
})
|
||||
.refine(
|
||||
(v) => v.size <= config.validation.emojis.max_bytes,
|
||||
`Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
|
||||
),
|
||||
),
|
||||
category: CustomEmojiSchema.shape.category.optional(),
|
||||
alt: CustomEmojiSchema.shape.description.optional(),
|
||||
global: CustomEmojiSchema.shape.global.default(false),
|
||||
});
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/emojis",
|
||||
summary: "Upload emoji",
|
||||
description: "Upload a new emoji to the server.",
|
||||
tags: ["Emojis"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/emojis",
|
||||
describeRoute({
|
||||
summary: "Upload emoji",
|
||||
description: "Upload a new emoji to the server.",
|
||||
tags: ["Emojis"],
|
||||
responses: {
|
||||
201: {
|
||||
description: "Uploaded emoji",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(CustomEmojiSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [
|
||||
|
|
@ -50,96 +39,99 @@ const route = createRoute({
|
|||
],
|
||||
}),
|
||||
jsonOrForm(),
|
||||
] as const,
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema,
|
||||
},
|
||||
"multipart/form-data": {
|
||||
schema,
|
||||
},
|
||||
"application/x-www-form-urlencoded": {
|
||||
schema,
|
||||
},
|
||||
},
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
shortcode: CustomEmojiSchema.shape.shortcode,
|
||||
element: z
|
||||
.string()
|
||||
.url()
|
||||
.transform((a) => new URL(a))
|
||||
.openapi({
|
||||
description: "Emoji image URL",
|
||||
})
|
||||
.or(
|
||||
z
|
||||
.instanceof(File)
|
||||
.openapi({
|
||||
description:
|
||||
"Emoji image encoded using multipart/form-data",
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.size <=
|
||||
config.validation.emojis.max_bytes,
|
||||
`Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
|
||||
),
|
||||
),
|
||||
category: CustomEmojiSchema.shape.category.optional(),
|
||||
alt: CustomEmojiSchema.shape.description.optional(),
|
||||
global: CustomEmojiSchema.shape.global.default(false),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { shortcode, element, alt, global, category } =
|
||||
context.req.valid("json");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
if (!user.hasPermission(RolePermission.ManageEmojis) && global) {
|
||||
throw new ApiError(
|
||||
401,
|
||||
"Missing permissions",
|
||||
`Only users with the '${RolePermission.ManageEmojis}' permission can upload global emojis`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
"Emoji already exists",
|
||||
`An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check of emoji is an image
|
||||
const contentType =
|
||||
element instanceof File
|
||||
? element.type
|
||||
: await mimeLookup(element);
|
||||
|
||||
if (!contentType.startsWith("image/")) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
"Invalid content type",
|
||||
`Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
||||
);
|
||||
}
|
||||
|
||||
const media =
|
||||
element instanceof File
|
||||
? await Media.fromFile(element, {
|
||||
description: alt ?? undefined,
|
||||
})
|
||||
: await Media.fromUrl(element, {
|
||||
description: alt ?? undefined,
|
||||
});
|
||||
|
||||
const emoji = await Emoji.insert({
|
||||
shortcode,
|
||||
mediaId: media.id,
|
||||
visibleInPicker: true,
|
||||
ownerId: global ? null : user.id,
|
||||
category,
|
||||
});
|
||||
|
||||
return context.json(emoji.toApi(), 201);
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Uploaded emoji",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: CustomEmojiSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
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.hasPermission(RolePermission.ManageEmojis) && global) {
|
||||
throw new ApiError(
|
||||
401,
|
||||
"Missing permissions",
|
||||
`Only users with the '${RolePermission.ManageEmojis}' permission can upload global emojis`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
"Emoji already exists",
|
||||
`An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check of emoji is an image
|
||||
const contentType =
|
||||
element instanceof File ? element.type : await mimeLookup(element);
|
||||
|
||||
if (!contentType.startsWith("image/")) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
"Invalid content type",
|
||||
`Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
||||
);
|
||||
}
|
||||
|
||||
const media =
|
||||
element instanceof File
|
||||
? await Media.fromFile(element, {
|
||||
description: alt ?? undefined,
|
||||
})
|
||||
: await Media.fromUrl(element, {
|
||||
description: alt ?? undefined,
|
||||
});
|
||||
|
||||
const emoji = await Emoji.insert({
|
||||
shortcode,
|
||||
mediaId: media.id,
|
||||
visibleInPicker: true,
|
||||
ownerId: global ? null : user.id,
|
||||
category,
|
||||
});
|
||||
|
||||
return context.json(emoji.toApi(), 201);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,100 +1,111 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Notes } from "@versia/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/favourites",
|
||||
summary: "View favourited statuses",
|
||||
description: "Statuses the user has favourited.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/favourites/#get",
|
||||
},
|
||||
tags: ["Favourites"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/favourites",
|
||||
describeRoute({
|
||||
summary: "View favourited statuses",
|
||||
description: "Statuses the user has favourited.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/favourites/#get",
|
||||
},
|
||||
tags: ["Favourites"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of favourited statuses",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(StatusSchema)),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/favourites?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/favourites?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnLikes],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
query: z.object({
|
||||
max_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of favourited statuses",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(StatusSchema),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: StatusSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.default(40)
|
||||
.openapi({
|
||||
description: "Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/favourites?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/favourites?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { max_id, since_id, min_id, limit } =
|
||||
context.req.valid("query");
|
||||
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const { objects: favourites, link } =
|
||||
await Timeline.getNoteTimeline(
|
||||
and(
|
||||
max_id ? lt(Notes.id, max_id) : undefined,
|
||||
since_id ? gte(Notes.id, since_id) : undefined,
|
||||
min_id ? gt(Notes.id, min_id) : undefined,
|
||||
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
user?.id,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(favourites.map((note) => note.toApi(user))),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
const { objects: favourites, link } = await Timeline.getNoteTimeline(
|
||||
and(
|
||||
max_id ? lt(Notes.id, max_id) : undefined,
|
||||
since_id ? gte(Notes.id, since_id) : undefined,
|
||||
min_id ? gt(Notes.id, min_id) : undefined,
|
||||
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
user?.id,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(favourites.map((note) => note.toApi(user))),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship, User } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/follow_requests/{account_id}/authorize",
|
||||
summary: "Accept follow request",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/follow_requests/#accept",
|
||||
},
|
||||
tags: ["Follows"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnFollows],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
account_id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Your Relationship with this account should be updated so that you are followed_by this account.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const { account_id } = context.req.valid("param");
|
||||
|
||||
const account = await User.fromId(account_id);
|
||||
|
||||
if (!account) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
||||
account,
|
||||
user,
|
||||
);
|
||||
|
||||
await oppositeRelationship.update({
|
||||
requested: false,
|
||||
following: true,
|
||||
});
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
account,
|
||||
);
|
||||
|
||||
// Check if accepting remote follow
|
||||
if (account.isRemote()) {
|
||||
// Federate follow accept
|
||||
await user.sendFollowAccept(account);
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship, User } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/follow_requests/{account_id}/reject",
|
||||
summary: "Reject follow request",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/follow_requests/#reject",
|
||||
},
|
||||
tags: ["Follows"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnFollows],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
account_id: AccountSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Your Relationship with this account should be unchanged.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: RelationshipSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const { account_id } = context.req.valid("param");
|
||||
|
||||
const account = await User.fromId(account_id);
|
||||
|
||||
if (!account) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
||||
account,
|
||||
user,
|
||||
);
|
||||
|
||||
await oppositeRelationship.update({
|
||||
requested: false,
|
||||
following: false,
|
||||
});
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
account,
|
||||
);
|
||||
|
||||
// Check if rejecting remote follow
|
||||
if (account.isRemote()) {
|
||||
// Federate follow reject
|
||||
await user.sendFollowReject(account);
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
82
api/api/v1/follow_requests/[account_id]/authorize.ts
Normal file
82
api/api/v1/follow_requests/[account_id]/authorize.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship, User } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/follow_requests/:account_id/authorize",
|
||||
describeRoute({
|
||||
summary: "Accept follow request",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/follow_requests/#accept",
|
||||
},
|
||||
tags: ["Follows"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Your Relationship with this account should be updated so that you are followed_by this account.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnFollows],
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
account_id: AccountSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const { account_id } = context.req.valid("param");
|
||||
|
||||
const account = await User.fromId(account_id);
|
||||
|
||||
if (!account) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
||||
account,
|
||||
user,
|
||||
);
|
||||
|
||||
await oppositeRelationship.update({
|
||||
requested: false,
|
||||
following: true,
|
||||
});
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
account,
|
||||
);
|
||||
|
||||
// Check if accepting remote follow
|
||||
if (account.isRemote()) {
|
||||
// Federate follow accept
|
||||
await user.sendFollowAccept(account);
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
83
api/api/v1/follow_requests/[account_id]/reject.ts
Normal file
83
api/api/v1/follow_requests/[account_id]/reject.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Relationship as RelationshipSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Relationship, User } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/follow_requests/:account_id/reject",
|
||||
describeRoute({
|
||||
summary: "Reject follow request",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/follow_requests/#reject",
|
||||
},
|
||||
tags: ["Follows"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Your Relationship with this account should be unchanged.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(RelationshipSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.accountNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnFollows],
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
account_id: AccountSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const { account_id } = context.req.valid("param");
|
||||
|
||||
const account = await User.fromId(account_id);
|
||||
|
||||
if (!account) {
|
||||
throw ApiError.accountNotFound();
|
||||
}
|
||||
|
||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
||||
account,
|
||||
user,
|
||||
);
|
||||
|
||||
await oppositeRelationship.update({
|
||||
requested: false,
|
||||
following: false,
|
||||
});
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
account,
|
||||
);
|
||||
|
||||
// Check if rejecting remote follow
|
||||
if (account.isRemote()) {
|
||||
// Federate follow reject
|
||||
await user.sendFollowReject(account);
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,101 +1,112 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/follow_requests",
|
||||
summary: "View pending follow requests",
|
||||
description: "Get a list of follow requests that the user has received.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/follow_requests/#get",
|
||||
},
|
||||
tags: ["Follows"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/follow_requests",
|
||||
describeRoute({
|
||||
summary: "View pending follow requests",
|
||||
description:
|
||||
"Get a list of follow requests that the user has received.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/follow_requests/#get",
|
||||
},
|
||||
tags: ["Follows"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"List of accounts that have requested to follow the user",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(AccountSchema)),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/follow_requests?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/follow_requests?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnFollows],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
query: z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"List of accounts that have requested to follow the user",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(AccountSchema),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.default(40)
|
||||
.openapi({
|
||||
description: "Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/follow_requests?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/follow_requests?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { max_id, since_id, min_id, limit } =
|
||||
context.req.valid("query");
|
||||
|
||||
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");
|
||||
|
||||
const { user } = context.get("auth");
|
||||
const { objects: followRequests, link } =
|
||||
await Timeline.getUserTimeline(
|
||||
and(
|
||||
max_id ? lt(Users.id, max_id) : undefined,
|
||||
since_id ? gte(Users.id, since_id) : undefined,
|
||||
min_id ? gt(Users.id, min_id) : undefined,
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
);
|
||||
|
||||
const { objects: followRequests, link } =
|
||||
await Timeline.getUserTimeline(
|
||||
and(
|
||||
max_id ? lt(Users.id, max_id) : undefined,
|
||||
since_id ? gte(Users.id, since_id) : undefined,
|
||||
min_id ? gt(Users.id, min_id) : undefined,
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
return context.json(
|
||||
followRequests.map((u) => u.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
|
||||
return context.json(
|
||||
followRequests.map((u) => u.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/frontend/config",
|
||||
summary: "Get frontend config",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Frontend config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.record(z.string(), z.any()).default({}),
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/frontend/config",
|
||||
describeRoute({
|
||||
summary: "Get frontend config",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Frontend config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.record(z.string(), z.any()).default({}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(context) => {
|
||||
return context.json(config.frontend.settings, 200);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, (context) => {
|
||||
return context.json(config.frontend.settings, 200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,46 +1,47 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { ExtendedDescription as ExtendedDescriptionSchema } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { markdownParse } from "~/classes/functions/status";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/instance/extended_description",
|
||||
summary: "View extended description",
|
||||
description: "Obtain an extended description of this server",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#extended_description",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Server extended description",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ExtendedDescriptionSchema,
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/instance/extended_description",
|
||||
describeRoute({
|
||||
summary: "View extended description",
|
||||
description: "Obtain an extended description of this server",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#extended_description",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Server extended description",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ExtendedDescriptionSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (context) => {
|
||||
const content = await markdownParse(
|
||||
config.instance.extended_description_path?.content ??
|
||||
"This is a [Versia](https://versia.pub) server with the default extended description.",
|
||||
);
|
||||
|
||||
return context.json(
|
||||
{
|
||||
updated_at: new Date(
|
||||
config.instance.extended_description_path?.file
|
||||
.lastModified ?? 0,
|
||||
).toISOString(),
|
||||
content,
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const content = await markdownParse(
|
||||
config.instance.extended_description_path?.content ??
|
||||
"This is a [Versia](https://versia.pub) server with the default extended description.",
|
||||
);
|
||||
|
||||
return context.json(
|
||||
{
|
||||
updated_at: new Date(
|
||||
config.instance.extended_description_path?.file
|
||||
.lastModified ?? 0,
|
||||
).toISOString(),
|
||||
content,
|
||||
},
|
||||
200,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,147 +1,144 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { apiRoute } from "@/api";
|
||||
import { proxyUrl } from "@/response";
|
||||
import { createRoute, type z } from "@hono/zod-openapi";
|
||||
import { InstanceV1 as InstanceV1Schema } from "@versia/client/schemas";
|
||||
import { Instance, Note, User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import type { z } from "zod";
|
||||
import { markdownParse } from "~/classes/functions/status";
|
||||
import { config } from "~/config.ts";
|
||||
import manifest from "~/package.json";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/instance",
|
||||
summary: "View server information (v1)",
|
||||
description:
|
||||
"Obtain general information about the server. See api/v2/instance instead.",
|
||||
deprecated: true,
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#v1",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
}),
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Instance information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: InstanceV1Schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
// Get software version from package.json
|
||||
const version = manifest.version;
|
||||
|
||||
const statusCount = await Note.getCount();
|
||||
|
||||
const userCount = await User.getCount();
|
||||
|
||||
// Get first admin, or first user if no admin exists
|
||||
const contactAccount =
|
||||
(await User.fromSql(
|
||||
and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
|
||||
)) ?? (await User.fromSql(isNull(Users.instanceId)));
|
||||
|
||||
const knownDomainsCount = await Instance.getCount();
|
||||
|
||||
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
|
||||
| {
|
||||
forced?: boolean;
|
||||
providers?: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const content = await markdownParse(
|
||||
config.instance.extended_description_path?.content ??
|
||||
"This is a [Versia](https://versia.pub) server with the default extended description.",
|
||||
);
|
||||
|
||||
return context.json({
|
||||
approval_required: config.registration.require_approval,
|
||||
configuration: {
|
||||
polls: {
|
||||
max_characters_per_option:
|
||||
config.validation.polls.max_option_characters,
|
||||
max_expiration:
|
||||
config.validation.polls.max_duration_seconds,
|
||||
max_options: config.validation.polls.max_options,
|
||||
min_expiration:
|
||||
config.validation.polls.min_duration_seconds,
|
||||
},
|
||||
statuses: {
|
||||
characters_reserved_per_url: 0,
|
||||
max_characters: config.validation.notes.max_characters,
|
||||
max_media_attachments:
|
||||
config.validation.notes.max_attachments,
|
||||
},
|
||||
media_attachments: {
|
||||
supported_mime_types:
|
||||
config.validation.media.allowed_mime_types,
|
||||
image_size_limit: config.validation.media.max_bytes,
|
||||
// TODO: Implement
|
||||
image_matrix_limit: 1 ** 10,
|
||||
video_size_limit: 1 ** 10,
|
||||
video_frame_rate_limit: 60,
|
||||
video_matrix_limit: 1 ** 10,
|
||||
},
|
||||
accounts: {
|
||||
max_featured_tags: 100,
|
||||
app.get(
|
||||
"/api/v1/instance",
|
||||
describeRoute({
|
||||
summary: "View server information (v1)",
|
||||
description:
|
||||
"Obtain general information about the server. See api/v2/instance instead.",
|
||||
deprecated: true,
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#v1",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Instance information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(InstanceV1Schema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
short_description: config.instance.description,
|
||||
description: content,
|
||||
email: config.instance.contact.email,
|
||||
invites_enabled: false,
|
||||
registrations: config.registration.allow,
|
||||
languages: config.instance.languages,
|
||||
rules: config.instance.rules.map((r, index) => ({
|
||||
id: String(index),
|
||||
text: r.text,
|
||||
hint: r.hint,
|
||||
})),
|
||||
stats: {
|
||||
domain_count: knownDomainsCount,
|
||||
status_count: statusCount,
|
||||
user_count: userCount,
|
||||
},
|
||||
thumbnail: config.instance.branding.logo
|
||||
? proxyUrl(config.instance.branding.logo).toString()
|
||||
: null,
|
||||
title: config.instance.name,
|
||||
uri: config.http.base_url.host,
|
||||
urls: {
|
||||
// TODO: Implement Streaming API
|
||||
streaming_api: "",
|
||||
},
|
||||
version: "4.3.0-alpha.3+glitch",
|
||||
versia_version: version,
|
||||
// TODO: Put into plugin directly
|
||||
sso: {
|
||||
forced: oidcConfig?.forced ?? false,
|
||||
providers:
|
||||
oidcConfig?.providers?.map((p) => ({
|
||||
name: p.name,
|
||||
icon: p.icon
|
||||
? proxyUrl(new URL(p.icon)).toString()
|
||||
: undefined,
|
||||
id: p.id,
|
||||
})) ?? [],
|
||||
},
|
||||
contact_account: (contactAccount as User)?.toApi(),
|
||||
} satisfies z.infer<typeof InstanceV1Schema>);
|
||||
}),
|
||||
}),
|
||||
async (context) => {
|
||||
// Get software version from package.json
|
||||
const version = manifest.version;
|
||||
|
||||
const statusCount = await Note.getCount();
|
||||
|
||||
const userCount = await User.getCount();
|
||||
|
||||
// Get first admin, or first user if no admin exists
|
||||
const contactAccount =
|
||||
(await User.fromSql(
|
||||
and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
|
||||
)) ?? (await User.fromSql(isNull(Users.instanceId)));
|
||||
|
||||
const knownDomainsCount = await Instance.getCount();
|
||||
|
||||
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
|
||||
| {
|
||||
forced?: boolean;
|
||||
providers?: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const content = await markdownParse(
|
||||
config.instance.extended_description_path?.content ??
|
||||
"This is a [Versia](https://versia.pub) server with the default extended description.",
|
||||
);
|
||||
|
||||
return context.json({
|
||||
approval_required: config.registration.require_approval,
|
||||
configuration: {
|
||||
polls: {
|
||||
max_characters_per_option:
|
||||
config.validation.polls.max_option_characters,
|
||||
max_expiration:
|
||||
config.validation.polls.max_duration_seconds,
|
||||
max_options: config.validation.polls.max_options,
|
||||
min_expiration:
|
||||
config.validation.polls.min_duration_seconds,
|
||||
},
|
||||
statuses: {
|
||||
characters_reserved_per_url: 0,
|
||||
max_characters: config.validation.notes.max_characters,
|
||||
max_media_attachments:
|
||||
config.validation.notes.max_attachments,
|
||||
},
|
||||
media_attachments: {
|
||||
supported_mime_types:
|
||||
config.validation.media.allowed_mime_types,
|
||||
image_size_limit: config.validation.media.max_bytes,
|
||||
// TODO: Implement
|
||||
image_matrix_limit: 1 ** 10,
|
||||
video_size_limit: 1 ** 10,
|
||||
video_frame_rate_limit: 60,
|
||||
video_matrix_limit: 1 ** 10,
|
||||
},
|
||||
accounts: {
|
||||
max_featured_tags: 100,
|
||||
},
|
||||
},
|
||||
short_description: config.instance.description,
|
||||
description: content,
|
||||
email: config.instance.contact.email,
|
||||
invites_enabled: false,
|
||||
registrations: config.registration.allow,
|
||||
languages: config.instance.languages,
|
||||
rules: config.instance.rules.map((r, index) => ({
|
||||
id: String(index),
|
||||
text: r.text,
|
||||
hint: r.hint,
|
||||
})),
|
||||
stats: {
|
||||
domain_count: knownDomainsCount,
|
||||
status_count: statusCount,
|
||||
user_count: userCount,
|
||||
},
|
||||
thumbnail: config.instance.branding.logo
|
||||
? proxyUrl(config.instance.branding.logo).toString()
|
||||
: null,
|
||||
title: config.instance.name,
|
||||
uri: config.http.base_url.host,
|
||||
urls: {
|
||||
// TODO: Implement Streaming API
|
||||
streaming_api: "",
|
||||
},
|
||||
version: "4.3.0-alpha.3+glitch",
|
||||
versia_version: version,
|
||||
// TODO: Put into plugin directly
|
||||
sso: {
|
||||
forced: oidcConfig?.forced ?? false,
|
||||
providers:
|
||||
oidcConfig?.providers?.map((p) => ({
|
||||
name: p.name,
|
||||
icon: p.icon
|
||||
? proxyUrl(new URL(p.icon)).toString()
|
||||
: undefined,
|
||||
id: p.id,
|
||||
})) ?? [],
|
||||
},
|
||||
contact_account: (contactAccount as User)?.toApi(),
|
||||
} satisfies z.infer<typeof InstanceV1Schema>);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,47 +1,43 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { apiRoute } from "@/api";
|
||||
import { PrivacyPolicy as PrivacyPolicySchema } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { markdownParse } from "~/classes/functions/status";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/instance/privacy_policy",
|
||||
summary: "View privacy policy",
|
||||
description: "Obtain the contents of this server’s privacy policy.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#privacy_policy",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
}),
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Server privacy policy",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: PrivacyPolicySchema,
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/instance/privacy_policy",
|
||||
describeRoute({
|
||||
summary: "View privacy policy",
|
||||
description: "Obtain the contents of this server’s privacy policy.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#privacy_policy",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Server privacy policy",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(PrivacyPolicySchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (context) => {
|
||||
const content = await markdownParse(
|
||||
config.instance.privacy_policy_path?.content ??
|
||||
"This instance has not provided any privacy policy.",
|
||||
);
|
||||
|
||||
return context.json({
|
||||
updated_at: new Date(
|
||||
config.instance.privacy_policy_path?.file.lastModified ?? 0,
|
||||
).toISOString(),
|
||||
content,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const content = await markdownParse(
|
||||
config.instance.privacy_policy_path?.content ??
|
||||
"This instance has not provided any privacy policy.",
|
||||
);
|
||||
|
||||
return context.json({
|
||||
updated_at: new Date(
|
||||
config.instance.privacy_policy_path?.file.lastModified ?? 0,
|
||||
).toISOString(),
|
||||
content,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,42 +1,39 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute } from "@/api";
|
||||
import { Rule as RuleSchema } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/instance/rules",
|
||||
summary: "List of rules",
|
||||
description: "Rules that the users of this service should follow.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#rules",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
}),
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Instance rules",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(RuleSchema),
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/instance/rules",
|
||||
describeRoute({
|
||||
summary: "List of rules",
|
||||
description: "Rules that the users of this service should follow.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#rules",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Instance rules",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(RuleSchema)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(context) => {
|
||||
return context.json(
|
||||
config.instance.rules.map((r, index) => ({
|
||||
id: String(index),
|
||||
text: r.text,
|
||||
hint: r.hint,
|
||||
})),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, (context) => {
|
||||
return context.json(
|
||||
config.instance.rules.map((r, index) => ({
|
||||
id: String(index),
|
||||
text: r.text,
|
||||
hint: r.hint,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,48 +1,44 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { apiRoute } from "@/api";
|
||||
import { TermsOfService as TermsOfServiceSchema } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { markdownParse } from "~/classes/functions/status";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/instance/terms_of_service",
|
||||
summary: "View terms of service",
|
||||
description:
|
||||
"Obtain the contents of this server’s terms of service, if configured.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#terms_of_service",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: false,
|
||||
}),
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Server terms of service",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: TermsOfServiceSchema,
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/instance/terms_of_service",
|
||||
describeRoute({
|
||||
summary: "View terms of service",
|
||||
description:
|
||||
"Obtain the contents of this server’s terms of service, if configured.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/instance/#terms_of_service",
|
||||
},
|
||||
tags: ["Instance"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Server terms of service",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(TermsOfServiceSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (context) => {
|
||||
const content = await markdownParse(
|
||||
config.instance.tos_path?.content ??
|
||||
"This instance has not provided any terms of service.",
|
||||
);
|
||||
|
||||
return context.json({
|
||||
updated_at: new Date(
|
||||
config.instance.tos_path?.file.lastModified ?? 0,
|
||||
).toISOString(),
|
||||
content,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const content = await markdownParse(
|
||||
config.instance.tos_path?.content ??
|
||||
"This instance has not provided any terms of service.",
|
||||
);
|
||||
|
||||
return context.json({
|
||||
updated_at: new Date(
|
||||
config.instance.tos_path?.file.lastModified ?? 0,
|
||||
).toISOString(),
|
||||
content,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import {
|
||||
Marker as MarkerSchema,
|
||||
Notification as NotificationSchema,
|
||||
|
|
@ -9,6 +8,9 @@ import { RolePermission } from "@versia/client/schemas";
|
|||
import { db } from "@versia/kit/db";
|
||||
import { Markers } from "@versia/kit/tables";
|
||||
import { type SQL, and, eq } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const MarkerResponseSchema = z.object({
|
||||
|
|
@ -16,221 +18,231 @@ const MarkerResponseSchema = z.object({
|
|||
home: MarkerSchema.optional(),
|
||||
});
|
||||
|
||||
const routeGet = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/markers",
|
||||
summary: "Get saved timeline positions",
|
||||
description: "Get current positions in timelines.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/markers/#get",
|
||||
},
|
||||
tags: ["Timelines"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnAccount],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
query: z.object({
|
||||
"timeline[]": z
|
||||
.array(z.enum(["home", "notifications"]))
|
||||
.max(2)
|
||||
.or(z.enum(["home", "notifications"]).transform((t) => [t]))
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Specify the timeline(s) for which markers should be fetched. Possible values: home, notifications. If not provided, an empty object will be returned.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Markers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: MarkerResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
const routePost = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/markers",
|
||||
summary: "Save your position in a timeline",
|
||||
description: "Save current position in timeline.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/markers/#create",
|
||||
},
|
||||
tags: ["Timelines"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnAccount],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
query: z
|
||||
.object({
|
||||
"home[last_read_id]": StatusSchema.shape.id.openapi({
|
||||
description:
|
||||
"ID of the last status read in the home timeline.",
|
||||
example: "c62aa212-8198-4ce5-a388-2cc8344a84ef",
|
||||
}),
|
||||
"notifications[last_read_id]":
|
||||
NotificationSchema.shape.id.openapi({
|
||||
description: "ID of the last notification read.",
|
||||
}),
|
||||
})
|
||||
.partial(),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Markers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: MarkerResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.openapi(routeGet, async (context) => {
|
||||
const { "timeline[]": timeline } = context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
app.get(
|
||||
"/api/v1/markers",
|
||||
describeRoute({
|
||||
summary: "Get saved timeline positions",
|
||||
description: "Get current positions in timelines.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/markers/#get",
|
||||
},
|
||||
tags: ["Timelines"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Markers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MarkerResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnAccount],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
"timeline[]": z
|
||||
.array(z.enum(["home", "notifications"]))
|
||||
.max(2)
|
||||
.or(z.enum(["home", "notifications"]).transform((t) => [t]))
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Specify the timeline(s) for which markers should be fetched. Possible values: home, notifications. If not provided, an empty object will be returned.",
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { "timeline[]": timeline } = context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
if (!timeline) {
|
||||
return context.json({}, 200);
|
||||
}
|
||||
if (!timeline) {
|
||||
return context.json({}, 200);
|
||||
}
|
||||
|
||||
const markers: z.infer<typeof MarkerResponseSchema> = {
|
||||
home: undefined,
|
||||
notifications: undefined,
|
||||
};
|
||||
const markers: z.infer<typeof MarkerResponseSchema> = {
|
||||
home: undefined,
|
||||
notifications: undefined,
|
||||
};
|
||||
|
||||
if (timeline.includes("home")) {
|
||||
const found = await db.query.Markers.findFirst({
|
||||
where: (marker, { and, eq }): SQL | undefined =>
|
||||
if (timeline.includes("home")) {
|
||||
const found = await db.query.Markers.findFirst({
|
||||
where: (marker, { and, eq }): SQL | undefined =>
|
||||
and(
|
||||
eq(marker.userId, user.id),
|
||||
eq(marker.timeline, "home"),
|
||||
),
|
||||
});
|
||||
|
||||
const totalCount = await db.$count(
|
||||
Markers,
|
||||
and(
|
||||
eq(marker.userId, user.id),
|
||||
eq(marker.timeline, "home"),
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "home"),
|
||||
),
|
||||
});
|
||||
);
|
||||
|
||||
const totalCount = await db.$count(
|
||||
Markers,
|
||||
and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")),
|
||||
);
|
||||
if (found?.noteId) {
|
||||
markers.home = {
|
||||
last_read_id: found.noteId,
|
||||
version: totalCount,
|
||||
updated_at: new Date(found.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (timeline.includes("notifications")) {
|
||||
const found = await db.query.Markers.findFirst({
|
||||
where: (marker, { and, eq }): SQL | undefined =>
|
||||
and(
|
||||
eq(marker.userId, user.id),
|
||||
eq(marker.timeline, "notifications"),
|
||||
),
|
||||
});
|
||||
|
||||
const totalCount = await db.$count(
|
||||
Markers,
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "notifications"),
|
||||
),
|
||||
);
|
||||
|
||||
if (found?.notificationId) {
|
||||
markers.notifications = {
|
||||
last_read_id: found.notificationId,
|
||||
version: totalCount,
|
||||
updated_at: new Date(found.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return context.json(markers, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/markers",
|
||||
describeRoute({
|
||||
summary: "Save your position in a timeline",
|
||||
description: "Save current position in timeline.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/markers/#create",
|
||||
},
|
||||
tags: ["Timelines"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Markers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MarkerResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnAccount],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z
|
||||
.object({
|
||||
"home[last_read_id]": StatusSchema.shape.id.openapi({
|
||||
description:
|
||||
"ID of the last status read in the home timeline.",
|
||||
example: "c62aa212-8198-4ce5-a388-2cc8344a84ef",
|
||||
}),
|
||||
"notifications[last_read_id]":
|
||||
NotificationSchema.shape.id.openapi({
|
||||
description: "ID of the last notification read.",
|
||||
}),
|
||||
})
|
||||
.partial(),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const {
|
||||
"home[last_read_id]": homeId,
|
||||
"notifications[last_read_id]": notificationsId,
|
||||
} = context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const markers: z.infer<typeof MarkerResponseSchema> = {
|
||||
home: undefined,
|
||||
notifications: undefined,
|
||||
};
|
||||
|
||||
if (homeId) {
|
||||
const insertedMarker = (
|
||||
await db
|
||||
.insert(Markers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
timeline: "home",
|
||||
noteId: homeId,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
const totalCount = await db.$count(
|
||||
Markers,
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "home"),
|
||||
),
|
||||
);
|
||||
|
||||
if (found?.noteId) {
|
||||
markers.home = {
|
||||
last_read_id: found.noteId,
|
||||
last_read_id: homeId,
|
||||
version: totalCount,
|
||||
updated_at: new Date(found.createdAt).toISOString(),
|
||||
updated_at: new Date(
|
||||
insertedMarker.createdAt,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (timeline.includes("notifications")) {
|
||||
const found = await db.query.Markers.findFirst({
|
||||
where: (marker, { and, eq }): SQL | undefined =>
|
||||
if (notificationsId) {
|
||||
const insertedMarker = (
|
||||
await db
|
||||
.insert(Markers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
timeline: "notifications",
|
||||
notificationId: notificationsId,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
const totalCount = await db.$count(
|
||||
Markers,
|
||||
and(
|
||||
eq(marker.userId, user.id),
|
||||
eq(marker.timeline, "notifications"),
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "notifications"),
|
||||
),
|
||||
});
|
||||
);
|
||||
|
||||
const totalCount = await db.$count(
|
||||
Markers,
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "notifications"),
|
||||
),
|
||||
);
|
||||
|
||||
if (found?.notificationId) {
|
||||
markers.notifications = {
|
||||
last_read_id: found.notificationId,
|
||||
last_read_id: notificationsId,
|
||||
version: totalCount,
|
||||
updated_at: new Date(found.createdAt).toISOString(),
|
||||
updated_at: new Date(
|
||||
insertedMarker.createdAt,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return context.json(markers, 200);
|
||||
});
|
||||
|
||||
app.openapi(routePost, async (context) => {
|
||||
const {
|
||||
"home[last_read_id]": homeId,
|
||||
"notifications[last_read_id]": notificationsId,
|
||||
} = context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const markers: z.infer<typeof MarkerResponseSchema> = {
|
||||
home: undefined,
|
||||
notifications: undefined,
|
||||
};
|
||||
|
||||
if (homeId) {
|
||||
const insertedMarker = (
|
||||
await db
|
||||
.insert(Markers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
timeline: "home",
|
||||
noteId: homeId,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
const totalCount = await db.$count(
|
||||
Markers,
|
||||
and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")),
|
||||
);
|
||||
|
||||
markers.home = {
|
||||
last_read_id: homeId,
|
||||
version: totalCount,
|
||||
updated_at: new Date(insertedMarker.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (notificationsId) {
|
||||
const insertedMarker = (
|
||||
await db
|
||||
.insert(Markers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
timeline: "notifications",
|
||||
notificationId: notificationsId,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
const totalCount = await db.$count(
|
||||
Markers,
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "notifications"),
|
||||
),
|
||||
);
|
||||
|
||||
markers.notifications = {
|
||||
last_read_id: notificationsId,
|
||||
version: totalCount,
|
||||
updated_at: new Date(insertedMarker.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return context.json(markers, 200);
|
||||
});
|
||||
return context.json(markers, 200);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { Attachment as AttachmentSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Media } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const routePut = createRoute({
|
||||
method: "put",
|
||||
path: "/api/v1/media/{id}",
|
||||
summary: "Update media attachment",
|
||||
description:
|
||||
"Update a MediaAttachment’s parameters, before it is attached to a status and posted.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/media/#update",
|
||||
},
|
||||
tags: ["Media"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:media"],
|
||||
permissions: [RolePermission.ManageOwnMedia],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AttachmentSchema.shape.id,
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: z
|
||||
.object({
|
||||
thumbnail: z.instanceof(File).openapi({
|
||||
description:
|
||||
"The custom thumbnail of the media to be attached, encoded using multipart form data.",
|
||||
}),
|
||||
description: AttachmentSchema.shape.description,
|
||||
focus: z.string().openapi({
|
||||
description:
|
||||
"Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
|
||||
},
|
||||
}),
|
||||
})
|
||||
.partial(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated attachment",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: AttachmentSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Attachment not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
const routeGet = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/media/{id}",
|
||||
summary: "Get media attachment",
|
||||
description:
|
||||
"Get a media attachment, before it is attached to a status and posted, but after it is accepted for processing. Use this method to check that the full-sized media has finished processing.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/media/#get",
|
||||
},
|
||||
tags: ["Media"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnMedia],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: AttachmentSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Attachment",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: AttachmentSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Attachment not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.openapi(routePut, async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const media = await Media.fromId(id);
|
||||
|
||||
if (!media) {
|
||||
throw ApiError.mediaNotFound();
|
||||
}
|
||||
|
||||
const { description, thumbnail: thumbnailFile } =
|
||||
context.req.valid("form");
|
||||
|
||||
if (thumbnailFile) {
|
||||
await media.updateThumbnail(thumbnailFile);
|
||||
}
|
||||
|
||||
if (description) {
|
||||
await media.updateMetadata({
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(media.toApi(), 200);
|
||||
});
|
||||
|
||||
app.openapi(routeGet, async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const attachment = await Media.fromId(id);
|
||||
|
||||
if (!attachment) {
|
||||
throw ApiError.mediaNotFound();
|
||||
}
|
||||
|
||||
return context.json(attachment.toApi(), 200);
|
||||
});
|
||||
});
|
||||
154
api/api/v1/media/[id]/index.ts
Normal file
154
api/api/v1/media/[id]/index.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import { Attachment as AttachmentSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Media } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.get(
|
||||
"/api/v1/media/:id",
|
||||
describeRoute({
|
||||
summary: "Get media attachment",
|
||||
description:
|
||||
"Get a media attachment, before it is attached to a status and posted, but after it is accepted for processing. Use this method to check that the full-sized media has finished processing.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/media/#get",
|
||||
},
|
||||
tags: ["Media"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Attachment",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(AttachmentSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Attachment not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnMedia],
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: AttachmentSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const attachment = await Media.fromId(id);
|
||||
|
||||
if (!attachment) {
|
||||
throw ApiError.mediaNotFound();
|
||||
}
|
||||
|
||||
return context.json(attachment.toApi(), 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/api/v1/media/:id",
|
||||
describeRoute({
|
||||
summary: "Update media attachment",
|
||||
description:
|
||||
"Update a MediaAttachment’s parameters, before it is attached to a status and posted.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/media/#update",
|
||||
},
|
||||
tags: ["Media"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated attachment",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(AttachmentSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Attachment not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:media"],
|
||||
permissions: [RolePermission.ManageOwnMedia],
|
||||
}),
|
||||
validator(
|
||||
"form",
|
||||
z
|
||||
.object({
|
||||
thumbnail: z.instanceof(File).openapi({
|
||||
description:
|
||||
"The custom thumbnail of the media to be attached, encoded using multipart form data.",
|
||||
}),
|
||||
description: AttachmentSchema.shape.description,
|
||||
focus: z.string().openapi({
|
||||
description:
|
||||
"Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
|
||||
},
|
||||
}),
|
||||
})
|
||||
.partial(),
|
||||
handleZodError,
|
||||
),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: AttachmentSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const media = await Media.fromId(id);
|
||||
|
||||
if (!media) {
|
||||
throw ApiError.mediaNotFound();
|
||||
}
|
||||
|
||||
const { description, thumbnail: thumbnailFile } =
|
||||
context.req.valid("form");
|
||||
|
||||
if (thumbnailFile) {
|
||||
await media.updateThumbnail(thumbnailFile);
|
||||
}
|
||||
|
||||
if (description) {
|
||||
await media.updateMetadata({
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(media.toApi(), 200);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,98 +1,93 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import { Attachment as AttachmentSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Media } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/media",
|
||||
summary: "Upload media as an attachment (v1)",
|
||||
description:
|
||||
"Creates an attachment to be used with a new status. This method will return after the full sized media is done processing.",
|
||||
deprecated: true,
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/media/#v1",
|
||||
},
|
||||
tags: ["Media"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/media",
|
||||
describeRoute({
|
||||
summary: "Upload media as an attachment (v1)",
|
||||
description:
|
||||
"Creates an attachment to be used with a new status. This method will return after the full sized media is done processing.",
|
||||
deprecated: true,
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/media/#v1",
|
||||
},
|
||||
tags: ["Media"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Attachment created successfully. Note that the MediaAttachment will be created even if the file is not understood correctly due to failed processing.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(AttachmentSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
413: {
|
||||
description: "File too large",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
415: {
|
||||
description: "Disallowed file type",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ApiError.zodSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:media"],
|
||||
permissions: [RolePermission.ManageOwnMedia],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: z.object({
|
||||
file: z.instanceof(File).openapi({
|
||||
description:
|
||||
"The file to be attached, encoded using multipart form data. The file must have a MIME type.",
|
||||
}),
|
||||
thumbnail: z.instanceof(File).optional().openapi({
|
||||
description:
|
||||
"The custom thumbnail of the media to be attached, encoded using multipart form data.",
|
||||
}),
|
||||
validator(
|
||||
"form",
|
||||
z.object({
|
||||
file: z.instanceof(File).openapi({
|
||||
description:
|
||||
"The file to be attached, encoded using multipart form data. The file must have a MIME type.",
|
||||
}),
|
||||
thumbnail: z.instanceof(File).optional().openapi({
|
||||
description:
|
||||
"The custom thumbnail of the media to be attached, encoded using multipart form data.",
|
||||
}),
|
||||
description: AttachmentSchema.shape.description.optional(),
|
||||
focus: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
AttachmentSchema.shape.description.optional(),
|
||||
focus: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
|
||||
},
|
||||
}),
|
||||
"Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0. Used for media cropping on clients.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#focal-points",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Attachment created successfully. Note that the MediaAttachment will be created even if the file is not understood correctly due to failed processing.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: AttachmentSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
413: {
|
||||
description: "File too large",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
415: {
|
||||
description: "Disallowed file type",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ApiError.zodSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { file, thumbnail, description } = context.req.valid("form");
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { file, thumbnail, description } = context.req.valid("form");
|
||||
const attachment = await Media.fromFile(file, {
|
||||
thumbnail,
|
||||
description: description ?? undefined,
|
||||
});
|
||||
|
||||
const attachment = await Media.fromFile(file, {
|
||||
thumbnail,
|
||||
description: description ?? undefined,
|
||||
});
|
||||
|
||||
return context.json(attachment.toApi(), 200);
|
||||
}),
|
||||
return context.json(attachment.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,99 +1,109 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import { Account as AccountSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/mutes",
|
||||
summary: "View muted accounts",
|
||||
description: "View your mutes.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/mutes/#get",
|
||||
},
|
||||
tags: ["Mutes"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/mutes",
|
||||
describeRoute({
|
||||
summary: "View muted accounts",
|
||||
description: "View your mutes.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/mutes/#get",
|
||||
},
|
||||
tags: ["Mutes"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of muted users",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(AccountSchema)),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/mutes?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/mutes?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["read:mutes"],
|
||||
permissions: [RolePermission.ManageOwnMutes],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
query: z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of muted users",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(AccountSchema),
|
||||
},
|
||||
},
|
||||
headers: z.object({
|
||||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.default(40)
|
||||
.openapi({
|
||||
description: "Links to the next and previous pages",
|
||||
example:
|
||||
'<https://versia.social/api/v1/mutes?limit=2&max_id=359ae97f-78dd-43e7-8e13-1d8e1d7829b5>; rel="next", <https://versia.social/api/v1/mutes?limit=2&since_id=75e9f5a9-f455-48eb-8f60-435b4a088bc0>; rel="prev"',
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/api/guidelines/#pagination",
|
||||
},
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { max_id, since_id, limit, min_id } =
|
||||
context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const { objects: mutes, link } = await Timeline.getUserTimeline(
|
||||
and(
|
||||
max_id ? lt(Users.id, max_id) : undefined,
|
||||
since_id ? gte(Users.id, since_id) : undefined,
|
||||
min_id ? gt(Users.id, min_id) : undefined,
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
mutes.map((u) => u.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { max_id, since_id, limit, min_id } = context.req.valid("query");
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const { objects: mutes, link } = await Timeline.getUserTimeline(
|
||||
and(
|
||||
max_id ? lt(Users.id, max_id) : undefined,
|
||||
since_id ? gte(Users.id, since_id) : undefined,
|
||||
min_id ? gt(Users.id, min_id) : undefined,
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
);
|
||||
|
||||
return context.json(
|
||||
mutes.map((u) => u.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { Notification as NotificationSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Notification } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/notifications/{id}/dismiss",
|
||||
summary: "Dismiss a single notification",
|
||||
description: "Dismiss a single notification from the server.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/notifications/#dismiss",
|
||||
},
|
||||
tags: ["Notifications"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:notifications"],
|
||||
permissions: [RolePermission.ManageOwnNotifications],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: NotificationSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Notification with given ID successfully dismissed",
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
404: ApiError.notificationNotFound().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const notification = await Notification.fromId(id);
|
||||
|
||||
if (!notification || notification.data.notifiedId !== user.id) {
|
||||
throw ApiError.notificationNotFound();
|
||||
}
|
||||
|
||||
await notification.update({
|
||||
dismissed: true,
|
||||
});
|
||||
|
||||
return context.text("", 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { Notification as NotificationSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Notification } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/notifications/{id}",
|
||||
summary: "Get a single notification",
|
||||
description: "View information about a notification with a given ID.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/notifications/#get",
|
||||
},
|
||||
tags: ["Notifications"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnNotifications],
|
||||
scopes: ["read:notifications"],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
params: z.object({
|
||||
id: NotificationSchema.shape.id,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "A single Notification",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: NotificationSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.notificationNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const notification = await Notification.fromId(id, user.id);
|
||||
|
||||
if (!notification || notification.data.notifiedId !== user.id) {
|
||||
throw ApiError.notificationNotFound();
|
||||
}
|
||||
|
||||
return context.json(await notification.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { z } from "@hono/zod-openapi";
|
||||
import type { Notification } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
59
api/api/v1/notifications/[id]/dismiss.ts
Normal file
59
api/api/v1/notifications/[id]/dismiss.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import { Notification as NotificationSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Notification } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/notifications/:id/dismiss",
|
||||
describeRoute({
|
||||
summary: "Dismiss a single notification",
|
||||
description: "Dismiss a single notification from the server.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/notifications/#dismiss",
|
||||
},
|
||||
tags: ["Notifications"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Notification with given ID successfully dismissed",
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
404: ApiError.notificationNotFound().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
scopes: ["write:notifications"],
|
||||
permissions: [RolePermission.ManageOwnNotifications],
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: NotificationSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const notification = await Notification.fromId(id);
|
||||
|
||||
if (!notification || notification.data.notifiedId !== user.id) {
|
||||
throw ApiError.notificationNotFound();
|
||||
}
|
||||
|
||||
await notification.update({
|
||||
dismissed: true,
|
||||
});
|
||||
|
||||
return context.text("", 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { z } from "@hono/zod-openapi";
|
||||
import type { Notification } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
60
api/api/v1/notifications/[id]/index.ts
Normal file
60
api/api/v1/notifications/[id]/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import { Notification as NotificationSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Notification } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/notifications/:id",
|
||||
describeRoute({
|
||||
summary: "Get a single notification",
|
||||
description:
|
||||
"View information about a notification with a given ID.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/notifications/#get",
|
||||
},
|
||||
tags: ["Notifications"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "A single Notification",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(NotificationSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: ApiError.notificationNotFound().schema,
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnNotifications],
|
||||
scopes: ["read:notifications"],
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: NotificationSchema.shape.id,
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const notification = await Notification.fromId(id, user.id);
|
||||
|
||||
if (!notification || notification.data.notifiedId !== user.id) {
|
||||
throw ApiError.notificationNotFound();
|
||||
}
|
||||
|
||||
return context.json(await notification.toApi(), 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,38 +1,36 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/notifications/clear",
|
||||
summary: "Dismiss all notifications",
|
||||
description: "Clear all notifications from the server.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/notifications/#clear",
|
||||
},
|
||||
tags: ["Notifications"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/v1/notifications/clear",
|
||||
describeRoute({
|
||||
summary: "Dismiss all notifications",
|
||||
description: "Clear all notifications from the server.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/notifications/#clear",
|
||||
},
|
||||
tags: ["Notifications"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Notifications successfully cleared.",
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnNotifications],
|
||||
scopes: ["write:notifications"],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description: "Notifications successfully cleared.",
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
await user.clearAllNotifications();
|
||||
|
||||
return context.text("", 200);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
await user.clearAllNotifications();
|
||||
|
||||
return context.text("", 200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { z } from "@hono/zod-openapi";
|
||||
import type { Notification } from "@versia/client/schemas";
|
||||
import type { z } from "zod";
|
||||
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
|
|
|
|||
|
|
@ -1,45 +1,44 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const schemas = {
|
||||
query: z.object({
|
||||
"ids[]": z.array(z.string().uuid()),
|
||||
}),
|
||||
};
|
||||
|
||||
const route = createRoute({
|
||||
method: "delete",
|
||||
path: "/api/v1/notifications/destroy_multiple",
|
||||
summary: "Dismiss multiple notifications",
|
||||
tags: ["Notifications"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.delete(
|
||||
"/api/v1/notifications/destroy_multiple",
|
||||
describeRoute({
|
||||
summary: "Dismiss multiple notifications",
|
||||
tags: ["Notifications"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Notifications dismissed",
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnNotifications],
|
||||
scopes: ["write:notifications"],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
query: schemas.query,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Notifications dismissed",
|
||||
qsQuery(),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
ids: z.array(z.string().uuid()),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const { ids } = context.req.valid("query");
|
||||
|
||||
await user.clearSomeNotifications(ids);
|
||||
|
||||
return context.text("", 200);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
const { "ids[]": ids } = context.req.valid("query");
|
||||
|
||||
await user.clearSomeNotifications(ids);
|
||||
|
||||
return context.text("", 200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { apiRoute, auth, handleZodError } from "@/api";
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
Notification as NotificationSchema,
|
||||
|
|
@ -9,18 +8,34 @@ import { RolePermission } from "@versia/client/schemas";
|
|||
import { Timeline } from "@versia/kit/db";
|
||||
import { Notifications } from "@versia/kit/tables";
|
||||
import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/notifications",
|
||||
summary: "Get all notifications",
|
||||
description: "Notifications concerning the user.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/notifications/#get",
|
||||
},
|
||||
tags: ["Notifications"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/v1/notifications",
|
||||
describeRoute({
|
||||
summary: "Get all notifications",
|
||||
description: "Notifications concerning the user.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/notifications/#get",
|
||||
},
|
||||
tags: ["Notifications"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Notifications",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(NotificationSchema)),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [
|
||||
|
|
@ -28,135 +43,122 @@ const route = createRoute({
|
|||
RolePermission.ViewPrivateTimelines,
|
||||
],
|
||||
}),
|
||||
] as const,
|
||||
request: {
|
||||
query: z
|
||||
.object({
|
||||
max_id: NotificationSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: NotificationSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: NotificationSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.default(40)
|
||||
.openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
validator(
|
||||
"query",
|
||||
z
|
||||
.object({
|
||||
max_id: NotificationSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
types: z
|
||||
.array(NotificationSchema.shape.type)
|
||||
.optional()
|
||||
.openapi({
|
||||
description: "Types to include in the result.",
|
||||
since_id: NotificationSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
exclude_types: z
|
||||
.array(NotificationSchema.shape.type)
|
||||
.optional()
|
||||
.openapi({
|
||||
description: "Types to exclude from the results.",
|
||||
min_id: NotificationSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
account_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Return only notifications received from the specified account.",
|
||||
}),
|
||||
// TODO: Implement
|
||||
include_filtered: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Whether to include notifications filtered by the user’s NotificationPolicy.",
|
||||
}),
|
||||
})
|
||||
.refine((val) => {
|
||||
// Can't use both exclude_types and types
|
||||
return !(val.exclude_types && val.types);
|
||||
}, "Can't use both exclude_types and types"),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Notifications",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(NotificationSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.default(40)
|
||||
.openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
types: z
|
||||
.array(NotificationSchema.shape.type)
|
||||
.optional()
|
||||
.openapi({
|
||||
description: "Types to include in the result.",
|
||||
}),
|
||||
exclude_types: z
|
||||
.array(NotificationSchema.shape.type)
|
||||
.optional()
|
||||
.openapi({
|
||||
description: "Types to exclude from the results.",
|
||||
}),
|
||||
account_id: AccountSchema.shape.id.optional().openapi({
|
||||
description:
|
||||
"Return only notifications received from the specified account.",
|
||||
}),
|
||||
// TODO: Implement
|
||||
include_filtered: zBoolean.default(false).openapi({
|
||||
description:
|
||||
"Whether to include notifications filtered by the user's NotificationPolicy.",
|
||||
}),
|
||||
})
|
||||
.refine((val) => {
|
||||
// Can't use both exclude_types and types
|
||||
return !(val.exclude_types && val.types);
|
||||
}, "Can't use both exclude_types and types"),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
const {
|
||||
account_id,
|
||||
exclude_types,
|
||||
limit,
|
||||
max_id,
|
||||
min_id,
|
||||
since_id,
|
||||
types,
|
||||
} = context.req.valid("query");
|
||||
|
||||
const {
|
||||
account_id,
|
||||
exclude_types,
|
||||
limit,
|
||||
max_id,
|
||||
min_id,
|
||||
since_id,
|
||||
types,
|
||||
} = context.req.valid("query");
|
||||
|
||||
const { objects, link } = await Timeline.getNotificationTimeline(
|
||||
and(
|
||||
max_id ? lt(Notifications.id, max_id) : undefined,
|
||||
since_id ? gte(Notifications.id, since_id) : undefined,
|
||||
min_id ? gt(Notifications.id, min_id) : undefined,
|
||||
eq(Notifications.notifiedId, user.id),
|
||||
eq(Notifications.dismissed, false),
|
||||
account_id
|
||||
? eq(Notifications.accountId, account_id)
|
||||
: undefined,
|
||||
not(eq(Notifications.accountId, user.id)),
|
||||
types ? inArray(Notifications.type, types) : undefined,
|
||||
exclude_types
|
||||
? not(inArray(Notifications.type, exclude_types))
|
||||
: undefined,
|
||||
// Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
|
||||
// Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
|
||||
// Filters table has a userId and a context which is an array
|
||||
sql`NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "Filters"
|
||||
WHERE "Filters"."userId" = ${user.id}
|
||||
AND "Filters"."filter_action" = 'hide'
|
||||
AND EXISTS (
|
||||
const { objects, link } = await Timeline.getNotificationTimeline(
|
||||
and(
|
||||
max_id ? lt(Notifications.id, max_id) : undefined,
|
||||
since_id ? gte(Notifications.id, since_id) : undefined,
|
||||
min_id ? gt(Notifications.id, min_id) : undefined,
|
||||
eq(Notifications.notifiedId, user.id),
|
||||
eq(Notifications.dismissed, false),
|
||||
account_id
|
||||
? eq(Notifications.accountId, account_id)
|
||||
: undefined,
|
||||
not(eq(Notifications.accountId, user.id)),
|
||||
types ? inArray(Notifications.type, types) : undefined,
|
||||
exclude_types
|
||||
? not(inArray(Notifications.type, exclude_types))
|
||||
: undefined,
|
||||
// Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
|
||||
// Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
|
||||
// Filters table has a userId and a context which is an array
|
||||
sql`NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "FilterKeywords", "Notifications" as "n_inner", "Notes"
|
||||
WHERE "FilterKeywords"."filterId" = "Filters"."id"
|
||||
AND "n_inner"."noteId" = "Notes"."id"
|
||||
AND "Notes"."content" LIKE
|
||||
'%' || "FilterKeywords"."keyword" || '%'
|
||||
AND "n_inner"."id" = "Notifications"."id"
|
||||
)
|
||||
AND "Filters"."context" @> ARRAY['notifications']
|
||||
)`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
user.id,
|
||||
);
|
||||
FROM "Filters"
|
||||
WHERE "Filters"."userId" = ${user.id}
|
||||
AND "Filters"."filter_action" = 'hide'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "FilterKeywords", "Notifications" as "n_inner", "Notes"
|
||||
WHERE "FilterKeywords"."filterId" = "Filters"."id"
|
||||
AND "n_inner"."noteId" = "Notes"."id"
|
||||
AND "Notes"."content" LIKE
|
||||
'%' || "FilterKeywords"."keyword" || '%'
|
||||
AND "n_inner"."id" = "Notifications"."id"
|
||||
)
|
||||
AND "Filters"."context" @> ARRAY['notifications']
|
||||
)`,
|
||||
),
|
||||
limit,
|
||||
new URL(context.req.url),
|
||||
user.id,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((n) => n.toApi())),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
}),
|
||||
return context.json(
|
||||
await Promise.all(objects.map((n) => n.toApi())),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,48 +1,48 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { Account } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "delete",
|
||||
path: "/api/v1/profile/avatar",
|
||||
summary: "Delete profile avatar",
|
||||
description: "Deletes the avatar associated with the user’s profile.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar",
|
||||
},
|
||||
tags: ["Profile"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.delete(
|
||||
"/api/v1/profile/avatar",
|
||||
describeRoute({
|
||||
summary: "Delete profile avatar",
|
||||
description:
|
||||
"Deletes the avatar associated with the user’s profile.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar",
|
||||
},
|
||||
tags: ["Profile"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"The avatar was successfully deleted from the user’s profile. If there were no avatar associated with the profile, the response will still indicate a successful deletion.",
|
||||
content: {
|
||||
"application/json": {
|
||||
// TODO: Return a CredentialAccount
|
||||
schema: resolver(Account),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnAccount],
|
||||
scopes: ["write:account"],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"The avatar was successfully deleted from the user’s profile. If there were no avatar associated with the profile, the response will still indicate a successful deletion.",
|
||||
content: {
|
||||
"application/json": {
|
||||
// TODO: Return a CredentialAccount
|
||||
schema: Account,
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
await user.avatar?.delete();
|
||||
await user.reload();
|
||||
|
||||
return context.json(user.toApi(true), 200);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
await user.avatar?.delete();
|
||||
await user.reload();
|
||||
|
||||
return context.json(user.toApi(true), 200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { Account } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
const route = createRoute({
|
||||
method: "delete",
|
||||
path: "/api/v1/profile/header",
|
||||
summary: "Delete profile header",
|
||||
description: "Deletes the header image associated with the user’s profile.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-header",
|
||||
},
|
||||
tags: ["Profiles"],
|
||||
middleware: [
|
||||
export default apiRoute((app) =>
|
||||
app.delete(
|
||||
"/api/v1/profile/header",
|
||||
describeRoute({
|
||||
summary: "Delete profile header",
|
||||
description:
|
||||
"Deletes the header image associated with the user’s profile.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-header",
|
||||
},
|
||||
tags: ["Profiles"],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"The header was successfully deleted from the user’s profile. If there were no header associated with the profile, the response will still indicate a successful deletion.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Account),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.ManageOwnAccount],
|
||||
scopes: ["write:account"],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"The header was successfully deleted from the user’s profile. If there were no header associated with the profile, the response will still indicate a successful deletion.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Account,
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
await user.header?.delete();
|
||||
await user.reload();
|
||||
return context.json(user.toApi(true), 200);
|
||||
},
|
||||
401: ApiError.missingAuthentication().schema,
|
||||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user } = context.get("auth");
|
||||
|
||||
await user.header?.delete();
|
||||
await user.reload();
|
||||
return context.json(user.toApi(true), 200);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,34 +1,28 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "delete",
|
||||
path: "/api/v1/push/subscription",
|
||||
app.delete(
|
||||
"/api/v1/push/subscription",
|
||||
describeRoute({
|
||||
summary: "Remove current subscription",
|
||||
description: "Removes the current Web Push API subscription.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/push/#delete",
|
||||
},
|
||||
tags: ["Push Notifications"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"PushSubscription successfully deleted or did not exist previously.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({}),
|
||||
schema: resolver(z.object({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -36,6 +30,11 @@ export default apiRoute((app) =>
|
|||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
async (context) => {
|
||||
const { token } = context.get("auth");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { apiRoute, auth } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { WebPushSubscription as WebPushSubscriptionSchema } from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/push/subscription",
|
||||
app.get(
|
||||
"/api/v1/push/subscription",
|
||||
describeRoute({
|
||||
summary: "Get current subscription",
|
||||
description:
|
||||
"View the PushSubscription currently associated with this access token.",
|
||||
|
|
@ -17,19 +17,12 @@ export default apiRoute((app) =>
|
|||
url: "https://docs.joinmastodon.org/methods/push/#get",
|
||||
},
|
||||
tags: ["Push Notifications"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description: "WebPushSubscription",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: WebPushSubscriptionSchema,
|
||||
schema: resolver(WebPushSubscriptionSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -37,6 +30,11 @@ export default apiRoute((app) =>
|
|||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
async (context) => {
|
||||
const { token } = context.get("auth");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { auth, jsonOrForm } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
WebPushSubscriptionInput,
|
||||
WebPushSubscription as WebPushSubscriptionSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/push/subscription",
|
||||
app.post(
|
||||
"/api/v1/push/subscription",
|
||||
describeRoute({
|
||||
summary: "Subscribe to push notifications",
|
||||
description:
|
||||
"Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.",
|
||||
|
|
@ -21,30 +21,13 @@ export default apiRoute((app) =>
|
|||
url: "https://docs.joinmastodon.org/methods/push/#create",
|
||||
},
|
||||
tags: ["Push Notifications"],
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
jsonOrForm(),
|
||||
] as const,
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: WebPushSubscriptionInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"A new PushSubscription has been generated, which will send the requested alerts to your endpoint.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: WebPushSubscriptionSchema,
|
||||
schema: resolver(WebPushSubscriptionSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -52,6 +35,13 @@ export default apiRoute((app) =>
|
|||
422: ApiError.validationFailed().schema,
|
||||
},
|
||||
}),
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermission.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
jsonOrForm(),
|
||||
validator("json", WebPushSubscriptionInput, handleZodError),
|
||||
async (context) => {
|
||||
const { user, token } = context.get("auth");
|
||||
const { subscription, data, policy } = context.req.valid("json");
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue