refactor: 🚚 Organize code into sub-packages, instead of a single large package

This commit is contained in:
Jesse Wierzbinski 2025-06-15 04:38:20 +02:00
parent 79742f47dc
commit a6d3ebbeef
No known key found for this signature in database
366 changed files with 942 additions and 833 deletions

View file

@ -0,0 +1,229 @@
import { afterAll, describe, expect, test } from "bun:test";
import { Application } from "@versia/kit/db";
import { config } from "@versia-server/config";
import { randomUUIDv7 } from "bun";
import { randomString } from "@/math";
import { fakeRequest, getTestUsers } from "~/tests/utils";
const { users, deleteUsers, passwords } = await getTestUsers(1);
// Create application
const application = await Application.insert({
id: randomUUIDv7(),
name: "Test Application",
clientId: randomString(32, "hex"),
secret: "test",
redirectUri: "https://example.com",
scopes: "read write",
});
afterAll(async () => {
await deleteUsers();
await application.delete();
});
// /api/auth/login
describe("/api/auth/login", () => {
test("should get a JWT with email", async () => {
const formData = new FormData();
formData.append("identifier", users[0]?.data.email ?? "");
formData.append("password", passwords[0]);
const response = await fakeRequest(
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
method: "POST",
body: formData,
},
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
config.http.base_url,
);
expect(locationHeader.pathname).toBe("/oauth/consent");
expect(locationHeader.searchParams.get("client_id")).toBe(
application.data.clientId,
);
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
"https://example.com",
);
expect(locationHeader.searchParams.get("response_type")).toBe("code");
expect(locationHeader.searchParams.get("scope")).toBe("read write");
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
});
test("should get a JWT with username", async () => {
const formData = new FormData();
formData.append("identifier", users[0]?.data.username ?? "");
formData.append("password", passwords[0]);
const response = await fakeRequest(
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
method: "POST",
body: formData,
},
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
config.http.base_url,
);
expect(locationHeader.pathname).toBe("/oauth/consent");
expect(locationHeader.searchParams.get("client_id")).toBe(
application.data.clientId,
);
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
"https://example.com",
);
expect(locationHeader.searchParams.get("response_type")).toBe("code");
expect(locationHeader.searchParams.get("scope")).toBe("read write");
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
});
test("should have state in the URL", async () => {
const formData = new FormData();
formData.append("identifier", users[0]?.data.email ?? "");
formData.append("password", passwords[0]);
const response = await fakeRequest(
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write&state=abc`,
{
method: "POST",
body: formData,
},
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
config.http.base_url,
);
expect(locationHeader.pathname).toBe("/oauth/consent");
expect(locationHeader.searchParams.get("client_id")).toBe(
application.data.clientId,
);
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
"https://example.com",
);
expect(locationHeader.searchParams.get("response_type")).toBe("code");
expect(locationHeader.searchParams.get("scope")).toBe("read write");
expect(locationHeader.searchParams.get("state")).toBe("abc");
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
});
describe("should reject invalid credentials", () => {
// Redirects to /oauth/authorize on invalid
test("invalid email", async () => {
const formData = new FormData();
formData.append("identifier", "ababa@gmail.com");
formData.append("password", "password");
const response = await fakeRequest(
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
method: "POST",
body: formData,
},
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.pathname).toBe("/oauth/authorize");
expect(locationHeader.searchParams.get("error")).toBe(
"invalid_grant",
);
expect(locationHeader.searchParams.get("error_description")).toBe(
"Invalid identifier or password",
);
expect(response.headers.get("Set-Cookie")).toBeNull();
});
test("invalid username", async () => {
const formData = new FormData();
formData.append("identifier", "ababa");
formData.append("password", "password");
const response = await fakeRequest(
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
method: "POST",
body: formData,
},
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.pathname).toBe("/oauth/authorize");
expect(locationHeader.searchParams.get("error")).toBe(
"invalid_grant",
);
expect(locationHeader.searchParams.get("error_description")).toBe(
"Invalid identifier or password",
);
expect(response.headers.get("Set-Cookie")).toBeNull();
});
test("invalid password", async () => {
const formData = new FormData();
formData.append("identifier", users[0]?.data.email ?? "");
formData.append("password", "password");
const response = await fakeRequest(
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
method: "POST",
body: formData,
},
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.pathname).toBe("/oauth/authorize");
expect(locationHeader.searchParams.get("error")).toBe(
"invalid_grant",
);
expect(locationHeader.searchParams.get("error_description")).toBe(
"Invalid identifier or password",
);
expect(response.headers.get("Set-Cookie")).toBeNull();
});
});
});

View file

@ -0,0 +1,231 @@
import { ApiError } from "@versia/kit";
import { Application, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { config } from "@versia-server/config";
import { password as bunPassword } from "bun";
import { eq, or } from "drizzle-orm";
import type { Context } from "hono";
import { setCookie } from "hono/cookie";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { SignJWT } from "jose";
import { z } from "zod";
import { apiRoute, handleZodError } from "@/api";
const returnError = (
context: Context,
error: string,
description: string,
): Response => {
const searchParams = new URLSearchParams();
// 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, value);
}
}
searchParams.append("error", error);
searchParams.append("error_description", description);
return context.redirect(
new URL(
`${config.frontend.routes.login}?${searchParams.toString()}`,
config.http.base_url,
).toString(),
);
};
export default apiRoute((app) =>
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,
},
},
},
},
}),
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;
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 bunPassword.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()}`,
);
},
),
);

View file

@ -0,0 +1,76 @@
import { db } from "@versia/kit/db";
import { Applications, Tokens } from "@versia/kit/tables";
import { config } from "@versia-server/config";
import { and, eq } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, handleZodError } from "@/api";
/**
* OAuth Code flow
*/
export default apiRoute((app) =>
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),
}).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()}`,
);
},
),
);

View file

@ -0,0 +1,124 @@
import { afterAll, describe, expect, test } from "bun:test";
import { Application } from "@versia/kit/db";
import { config } from "@versia-server/config";
import { randomUUIDv7 } from "bun";
import { randomString } from "@/math";
import { fakeRequest, getTestUsers } from "~/tests/utils";
const { users, deleteUsers, passwords } = await getTestUsers(1);
const token = randomString(32, "hex");
const newPassword = randomString(16, "hex");
// Create application
const application = await Application.insert({
id: randomUUIDv7(),
name: "Test Application",
clientId: randomString(32, "hex"),
secret: "test",
redirectUri: "https://example.com",
scopes: "read write",
});
afterAll(async () => {
await deleteUsers();
await application.delete();
});
// /api/auth/reset
describe("/api/auth/reset", () => {
test("should login with normal password", async () => {
const formData = new FormData();
formData.append("identifier", users[0]?.data.username ?? "");
formData.append("password", passwords[0]);
const response = await fakeRequest(
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
method: "POST",
body: formData,
},
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
});
test("should reset password and refuse login with old password", async () => {
await users[0]?.update({
passwordResetToken: token,
});
const formData = new FormData();
formData.append("identifier", users[0]?.data.username ?? "");
formData.append("password", passwords[0]);
const response = await fakeRequest(
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
method: "POST",
body: formData,
},
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
config.http.base_url,
);
expect(locationHeader.pathname).toBe("/oauth/reset");
expect(locationHeader.searchParams.get("token")).toBe(token);
});
test("should reset password and login with new password", async () => {
const formData = new FormData();
formData.append("token", token);
formData.append("password", newPassword);
formData.append("password2", newPassword);
const response = await fakeRequest("/api/auth/reset", {
method: "POST",
body: formData,
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const loginFormData = new FormData();
loginFormData.append("identifier", users[0]?.data.username ?? "");
loginFormData.append("password", newPassword);
const loginResponse = await fakeRequest(
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
method: "POST",
body: loginFormData,
},
);
expect(loginResponse.status).toBe(302);
expect(loginResponse.headers.get("location")).toBeDefined();
const locationHeader = new URL(
loginResponse.headers.get("Location") ?? "",
config.http.base_url,
);
expect(locationHeader.pathname).toBe("/oauth/consent");
expect(locationHeader.searchParams.get("client_id")).toBe(
application.data.clientId,
);
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
"https://example.com",
);
expect(locationHeader.searchParams.get("response_type")).toBe("code");
expect(locationHeader.searchParams.get("scope")).toBe("read write");
expect(loginResponse.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
});
});

View file

@ -0,0 +1,81 @@
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { config } from "@versia-server/config";
import { password as bunPassword } from "bun";
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 { apiRoute, handleZodError } from "@/api";
const returnError = (
context: Context,
token: string,
error: string,
description: string,
): Response => {
const searchParams = new URLSearchParams();
searchParams.append("error", error);
searchParams.append("error_description", description);
searchParams.append("token", token);
return context.redirect(
new URL(
`${
config.frontend.routes.password_reset
}?${searchParams.toString()}`,
config.http.base_url,
).toString(),
);
};
export default apiRoute((app) =>
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",
);
}
await user.update({
password: await bunPassword.hash(password),
passwordResetToken: null,
});
return context.redirect(
`${config.frontend.routes.password_reset}?success=true`,
);
},
),
);

View file

@ -0,0 +1,49 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/:id/block
describe("/api/v1/accounts/:id/block", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.blockAccount(users[1].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if user not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.blockAccount(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should block user", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.blockAccount(users[1].id);
expect(ok).toBe(true);
expect(data.blocking).toBe(true);
});
test("should return 200 if user already blocked", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.blockAccount(users[1].id);
expect(ok).toBe(true);
expect(data.blocking).toBe(true);
});
});

View file

@ -0,0 +1,64 @@
import {
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withUserParam } from "@/api";
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);
},
),
);

View file

@ -0,0 +1,61 @@
import { RolePermission } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { getFeed } from "@/rss";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/feed.atom",
describeRoute({
summary: "Get account's Atom feed",
description:
"Statuses posted to the given account, in Atom 1.0 format.",
tags: ["Accounts"],
responses: {
200: {
description: "Statuses posted to the given account.",
content: {
"application/atom+xml": {
schema: resolver(z.any()),
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: false,
permissions: [
RolePermission.ViewNotes,
RolePermission.ViewAccounts,
],
scopes: ["read:statuses"],
}),
validator(
"query",
z.object({
page: z.coerce.number().default(0).openapi({
description: "Page number to fetch. Defaults to 0.",
example: 2,
}),
}),
handleZodError,
),
async (context) => {
const otherUser = context.get("user");
const { page } = context.req.valid("query");
const feed = await getFeed(otherUser, page);
context.header("Content-Type", "application/atom+xml");
return context.body(feed.atom1(), 200);
},
),
);

View file

@ -0,0 +1,60 @@
import { RolePermission } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { getFeed } from "@/rss";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/feed.rss",
describeRoute({
summary: "Get account's RSS feed",
description: "Statuses posted to the given account, in RSS format.",
tags: ["Accounts"],
responses: {
200: {
description: "Statuses posted to the given account.",
content: {
"application/rss+xml": {
schema: resolver(z.any()),
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: false,
permissions: [
RolePermission.ViewNotes,
RolePermission.ViewAccounts,
],
scopes: ["read:statuses"],
}),
validator(
"query",
z.object({
page: z.coerce.number().default(0).openapi({
description: "Page number to fetch. Defaults to 0.",
example: 2,
}),
}),
handleZodError,
),
async (context) => {
const otherUser = context.get("user");
const { page } = context.req.valid("query");
const feed = await getFeed(otherUser, page);
context.header("Content-Type", "application/rss+xml");
return context.body(feed.rss2(), 200);
},
),
);

View file

@ -0,0 +1,56 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(3);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts/:id/follow", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.followAccount(users[1].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if user not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.followAccount(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should follow user", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.followAccount(users[1].id);
expect(ok).toBe(true);
const { ok: ok2, data: data2 } = await client.getAccount(users[1].id);
expect(ok2).toBe(true);
expect(data2.followers_count).toBe(1);
const { ok: ok3, data: data3 } = await client.getAccount(users[0].id);
expect(ok3).toBe(true);
expect(data3.following_count).toBe(1);
});
test("should return 200 if user already followed", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.followAccount(users[1].id);
expect(ok).toBe(true);
});
});

View file

@ -0,0 +1,102 @@
import {
iso631,
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
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 accounts 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 accounts 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);
},
),
);

View file

@ -0,0 +1,76 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
await using client = await generateClient(users[0]);
// Follow user
const { ok, raw } = await client.followAccount(users[1].id);
expect(ok).toBe(true);
expect(raw.status).toBe(200);
});
// /api/v1/accounts/:id/followers
describe("/api/v1/accounts/:id/followers", () => {
test("should return 200 with followers", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.getAccountFollowers(users[1].id);
expect(ok).toBe(true);
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(1);
expect(data[0].id).toBe(users[0].id);
});
test("should return no followers after unfollowing", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.unfollowAccount(users[1].id);
expect(ok).toBe(true);
const { ok: ok2, data } = await client.getAccountFollowers(users[1].id);
expect(ok2).toBe(true);
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(0);
});
test("should return no followers if account is hiding collections", async () => {
await using client0 = await generateClient(users[0]);
await using client1 = await generateClient(users[1]);
const { ok: ok0 } = await client0.followAccount(users[1].id);
expect(ok0).toBe(true);
const { ok: ok1, data: data1 } = await client0.getAccountFollowers(
users[1].id,
);
expect(ok1).toBe(true);
expect(data1).toBeArrayOfSize(1);
const { ok: ok2 } = await client1.updateCredentials({
hide_collections: true,
});
expect(ok2).toBe(true);
const { ok: ok3, data: data3 } = await client0.getAccountFollowers(
users[1].id,
);
expect(ok3).toBe(true);
expect(data3).toBeArrayOfSize(0);
});
});

View file

@ -0,0 +1,118 @@
import {
Account as AccountSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth, handleZodError, withUserParam } from "@/api";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/followers",
describeRoute({
summary: "Get accounts 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: {
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 { user: self } = context.get("auth");
const { max_id, since_id, min_id, limit } =
context.req.valid("query");
const otherUser = context.get("user");
if (
self?.id !== otherUser.id &&
otherUser.data.isHidingCollections
) {
return context.json([], 200, { Link: "" });
}
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,
},
);
},
),
);

View file

@ -0,0 +1,75 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
await using client = await generateClient(users[0]);
// Follow user
const { ok } = await client.followAccount(users[1].id);
expect(ok).toBe(true);
});
// /api/v1/accounts/:id/following
describe("/api/v1/accounts/:id/following", () => {
test("should return 200 with following", async () => {
await using client = await generateClient(users[1]);
const { ok, data } = await client.getAccountFollowing(users[0].id);
expect(ok).toBe(true);
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(1);
expect(data[0].id).toBe(users[1].id);
});
test("should return no following after unfollowing", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.unfollowAccount(users[1].id);
expect(ok).toBe(true);
const { ok: ok2, data } = await client.getAccountFollowing(users[1].id);
expect(ok2).toBe(true);
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(0);
});
test("should return no following if account is hiding collections", async () => {
await using client0 = await generateClient(users[0]);
await using client1 = await generateClient(users[1]);
const { ok: ok0 } = await client1.followAccount(users[0].id);
expect(ok0).toBe(true);
const { ok: ok1, data: data1 } = await client0.getAccountFollowing(
users[1].id,
);
expect(ok1).toBe(true);
expect(data1).toBeArrayOfSize(1);
const { ok: ok2 } = await client1.updateCredentials({
hide_collections: true,
});
expect(ok2).toBe(true);
const { ok: ok3, data: data3 } = await client0.getAccountFollowing(
users[1].id,
);
expect(ok3).toBe(true);
expect(data3).toBeArrayOfSize(0);
});
});

View file

@ -0,0 +1,118 @@
import {
Account as AccountSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth, handleZodError, withUserParam } from "@/api";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/following",
describeRoute({
summary: "Get accounts 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: {
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 { user: self } = context.get("auth");
const { max_id, since_id, min_id } = context.req.valid("query");
const otherUser = context.get("user");
if (
self?.id !== otherUser.id &&
otherUser.data.isHidingCollections
) {
return context.json([], 200, { Link: "" });
}
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,
},
);
},
),
);

View file

@ -0,0 +1,72 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(5);
const timeline = (await getTestStatuses(5, users[0])).toReversed();
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
await using client = await generateClient(users[0]);
for (const status of timeline) {
await client.favouriteStatus(status.id);
}
});
// /api/v1/accounts/:id
describe("/api/v1/accounts/:id", () => {
test("should return 404 if ID is invalid", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.getAccount("invalid-id");
expect(ok).toBe(false);
expect(raw.status).toBe(422);
});
test("should return user", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.getAccount(users[0].id);
expect(ok).toBe(true);
expect(data).toMatchObject({
id: users[0].id,
username: users[0].data.username,
display_name: users[0].data.displayName,
avatar: expect.any(String),
header: expect.any(String),
locked: users[0].data.isLocked,
created_at: new Date(users[0].data.createdAt).toISOString(),
followers_count: 0,
following_count: 0,
statuses_count: 5,
note: users[0].data.note,
acct: users[0].data.username,
uri: expect.any(String),
url: expect.any(String),
avatar_static: expect.any(String),
header_static: expect.any(String),
emojis: [],
moved: null,
fields: [],
bot: false,
group: false,
limited: false,
noindex: false,
suspended: false,
roles: expect.arrayContaining([
expect.objectContaining({
id: "default",
name: "Default",
priority: 0,
description: "Default role for all users",
visible: false,
}),
]),
});
});
});

View file

@ -0,0 +1,49 @@
import {
Account as AccountSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withUserParam } from "@/api";
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,
);
},
),
);

View file

@ -0,0 +1,72 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/:id/mute
describe("/api/v1/accounts/:id/mute", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.muteAccount(users[1].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if user not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.muteAccount(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should mute user", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.muteAccount(users[1].id);
expect(ok).toBe(true);
expect(data.muting).toBe(true);
});
test("should return 200 if user already muted", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.muteAccount(users[1].id);
expect(ok).toBe(true);
expect(data.muting).toBe(true);
});
test("should unmute user after duration", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.muteAccount(users[1].id, {
duration: 1,
});
expect(ok).toBe(true);
expect(data.muting).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 1500));
const { data: data2, ok: ok2 } = await client.getRelationship(
users[1].id,
);
expect(ok2).toBe(true);
expect(data2.muting).toBe(false);
});
});

View file

@ -0,0 +1,101 @@
import {
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import {
RelationshipJobType,
relationshipQueue,
} from "~/classes/queues/relationships";
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. 0 means indefinite.",
}),
}),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const { notifications, duration } = context.req.valid("json");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
await foundRelationship.update({
muting: true,
mutingNotifications: notifications,
});
if (duration > 0) {
await relationshipQueue.add(
RelationshipJobType.Unmute,
{
ownerId: user.id,
subjectId: otherUser.id,
},
{
delay: duration * 1000,
},
);
}
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,64 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts/:id/note", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.updateAccountNote(users[1].id, "test");
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if user not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.updateAccountNote(
"00000000-0000-0000-0000-000000000000",
"test",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should update note", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.updateAccountNote(
users[1].id,
"test",
);
expect(ok).toBe(true);
expect(data.note).toBe("test");
});
test("should return 200 if note is null", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.updateAccountNote(users[1].id, null);
expect(ok).toBe(true);
expect(data.note).toBe("");
});
test("should return 422 if note is too long", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.updateAccountNote(
users[1].id,
"a".repeat(10_000),
);
expect(ok).toBe(false);
expect(raw.status).toBe(422);
});
});

View file

@ -0,0 +1,72 @@
import {
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
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);
},
),
);

View file

@ -0,0 +1,48 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts/:id/pin", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.pinAccount(users[1].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if user not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.pinAccount(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should pin account", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.pinAccount(users[1].id);
expect(ok).toBe(true);
expect(data.endorsed).toBe(true);
});
test("should return 200 if account already pinned", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.pinAccount(users[1].id);
expect(ok).toBe(true);
expect(data.endorsed).toBe(true);
});
});

View file

@ -0,0 +1,56 @@
import {
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withUserParam } from "@/api";
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 users featured profiles. (Featured profiles are currently shown on the users 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);
},
),
);

View file

@ -0,0 +1,59 @@
import {
Account as AccountSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { User } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withUserParam } from "@/api";
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.local) {
throw new ApiError(400, "Cannot refetch a local user");
}
const newUser = await User.fromVersia(otherUser.uri);
return context.json(newUser.toApi(false), 200);
},
),
);

View file

@ -0,0 +1,59 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
// Make users[1] follow users[0]
await using client = await generateClient(users[1]);
const { ok } = await client.followAccount(users[0].id);
expect(ok).toBe(true);
});
describe("/api/v1/accounts/:id/remove_from_followers", () => {
test("should return 401 when not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.removeFromFollowers(users[1].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 when target account doesn't exist", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.removeFromFollowers("non-existent-id");
expect(ok).toBe(false);
expect(raw.status).toBe(422);
});
test("should remove follower and return relationship", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.removeFromFollowers(users[1].id);
expect(ok).toBe(true);
expect(data.id).toBe(users[1].id);
expect(data.following).toBe(false);
expect(data.followed_by).toBe(false);
});
test("should handle case when user is not following", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.removeFromFollowers(users[1].id);
expect(ok).toBe(true);
expect(data.id).toBe(users[1].id);
expect(data.following).toBe(false);
expect(data.followed_by).toBe(false);
});
});

View file

@ -0,0 +1,68 @@
import {
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withUserParam } from "@/api";
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);
},
),
);

View file

@ -0,0 +1,146 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { RolePermission } from "@versia/client/schemas";
import { Role } from "@versia/kit/db";
import { randomUUIDv7 } from "bun";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
let role: Role;
let higherPriorityRole: Role;
beforeAll(async () => {
// Create new role
role = await Role.insert({
id: randomUUIDv7(),
name: "test",
permissions: [RolePermission.ManageRoles],
priority: 2,
description: "test",
visible: true,
icon: "https://test.com",
});
expect(role).toBeDefined();
await role.linkUser(users[0].id);
// Create a role with higher priority than the user's role
higherPriorityRole = await Role.insert({
id: randomUUIDv7(),
name: "higherPriorityRole",
permissions: [RolePermission.ManageRoles],
priority: 3, // Higher priority than the user's role
description: "Higher priority role",
visible: true,
});
expect(higherPriorityRole).toBeDefined();
});
afterAll(async () => {
await role.delete();
await higherPriorityRole.delete();
await deleteUsers();
});
// /api/v1/accounts/:id/roles/:role_id
describe("/api/v1/accounts/:id/roles/:role_id", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.assignRole(users[1].id, role.id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if role does not exist", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.assignRole(
users[1].id,
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should return 404 if user does not exist", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.assignRole(
"00000000-0000-0000-0000-000000000000",
role.id,
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should assign role to user", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.assignRole(users[1].id, role.id);
expect(ok).toBe(true);
// Check if role was assigned
const userRoles = await Role.getUserRoles(users[1].id, false);
expect(userRoles).toContainEqual(
expect.objectContaining({ id: role.id }),
);
});
test("should return 403 if user tries to assign role with higher priority", async () => {
await using client = await generateClient(users[0]);
const { data, ok, raw } = await client.assignRole(
users[1].id,
higherPriorityRole.id,
);
expect(ok).toBe(false);
expect(raw.status).toBe(403);
expect(data).toMatchObject({
error: "Forbidden",
details:
"User with highest role priority 2 cannot assign role with priority 3",
});
});
test("should remove role from user", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.unassignRole(users[1].id, role.id);
expect(ok).toBe(true);
// Check if role was removed
const userRoles = await Role.getUserRoles(users[1].id, false);
expect(userRoles).not.toContainEqual(
expect.objectContaining({ id: role.id }),
);
});
test("should return 403 if user tries to remove role with higher priority", async () => {
await higherPriorityRole.linkUser(users[1].id);
await using client = await generateClient(users[0]);
const { data, ok, raw } = await client.unassignRole(
users[1].id,
higherPriorityRole.id,
);
expect(ok).toBe(false);
expect(raw.status).toBe(403);
expect(data).toMatchObject({
error: "Forbidden",
details:
"User with highest role priority 2 cannot remove role with priority 3",
});
await higherPriorityRole.unlinkUser(users[1].id);
});
});

View file

@ -0,0 +1,127 @@
import {
Account as AccountSchema,
RolePermission,
Role as RoleSchema,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Role } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
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);
},
);
});

View file

@ -0,0 +1,64 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { RolePermission } from "@versia/client/schemas";
import { Role } from "@versia/kit/db";
import { randomUUIDv7 } from "bun";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
let role: Role;
beforeAll(async () => {
// Create new role
role = await Role.insert({
id: randomUUIDv7(),
name: "test",
permissions: [RolePermission.ManageRoles],
priority: 2,
description: "test",
visible: true,
icon: "https://test.com",
});
expect(role).toBeDefined();
await role.linkUser(users[0].id);
});
afterAll(async () => {
await role.delete();
await deleteUsers();
});
describe("/api/v1/accounts/:id/roles", () => {
test("should return 404 if user does not exist", async () => {
await using client = await generateClient(users[0]);
const { data, ok, raw } = await client.getAccountRoles(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
expect(data).toMatchObject({
error: "User not found",
});
});
test("should return a list of roles for the user", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getAccountRoles(users[0].id);
expect(ok).toBe(true);
expect(data).toBeArray();
expect(data).toContainEqual({
id: role.id,
name: "test",
permissions: [RolePermission.ManageRoles],
priority: 2,
description: "test",
visible: true,
icon: expect.any(String),
});
});
});

View file

@ -0,0 +1,43 @@
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";
import { apiRoute, auth, withUserParam } from "@/api";
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,
);
},
);
});

View file

@ -0,0 +1,109 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import {
generateClient,
getTestStatuses,
getTestUsers,
} from "~/tests/utils.ts";
const { users, deleteUsers } = await getTestUsers(5);
const timeline = (await getTestStatuses(5, users[1])).toReversed();
const timeline2 = (await getTestStatuses(5, users[2])).toReversed();
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
await using client = await generateClient(users[1]);
const { ok } = await client.reblogStatus(timeline2[0].id);
expect(ok).toBe(true);
});
// /api/v1/accounts/:id/statuses
describe("/api/v1/accounts/:id/statuses", () => {
test("should return 200 with statuses", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.getAccountStatuses(users[1].id, {
limit: 5,
});
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(5);
// Should have reblogs
expect(data[0].reblog).toBeDefined();
});
test("should exclude reblogs", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.getAccountStatuses(users[1].id, {
exclude_reblogs: true,
limit: 5,
});
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(5);
// Should not have reblogs
expect(data[0].reblog).toBeNull();
});
test("should exclude replies", async () => {
// Create reply
await using client0 = await generateClient(users[0]);
await using client1 = await generateClient(users[1]);
const { ok } = await client1.postStatus("Reply", {
in_reply_to_id: timeline[0].id,
local_only: true,
});
expect(ok).toBe(true);
const { data, ok: ok2 } = await client0.getAccountStatuses(
users[1].id,
{
exclude_replies: true,
limit: 5,
},
);
expect(ok2).toBe(true);
expect(data).toBeArrayOfSize(5);
// Should not have replies
expect(data[0].in_reply_to_id).toBeNull();
});
test("should only include pins", async () => {
await using client0 = await generateClient(users[0]);
await using client1 = await generateClient(users[1]);
const { ok, data } = await client0.getAccountStatuses(users[1].id, {
pinned: true,
});
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(0);
// Create pin
const { ok: ok2 } = await client1.pinStatus(timeline[3].id);
expect(ok2).toBe(true);
const { data: data3, ok: ok3 } = await client0.getAccountStatuses(
users[1].id,
{
pinned: true,
},
);
expect(ok3).toBe(true);
expect(data3).toBeArrayOfSize(1);
expect(data3[0].id).toBe(timeline[3].id);
});
});

View file

@ -0,0 +1,145 @@
import {
RolePermission,
Status as StatusSchema,
zBoolean,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth, handleZodError, withUserParam } from "@/api";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/statuses",
describeRoute({
summary: "Get accounts 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,
);
},
),
);

View file

@ -0,0 +1,55 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts/:id/unblock", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.unblockAccount(users[1].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if user not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.unblockAccount(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should unblock user", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.blockAccount(users[1].id);
expect(ok).toBe(true);
expect(data.blocking).toBe(true);
const { ok: ok2, data: data2 } = await client.unblockAccount(
users[1].id,
);
expect(ok2).toBe(true);
expect(data2.blocking).toBe(false);
});
test("should return 200 if user already unblocked", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.unblockAccount(users[1].id);
expect(ok).toBe(true);
expect(data.blocking).toBe(false);
});
});

View file

@ -0,0 +1,63 @@
import {
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withUserParam } from "@/api";
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);
},
),
);

View file

@ -0,0 +1,60 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(3);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts/:id/unfollow", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.unfollowAccount(users[1].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if user not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.unfollowAccount(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should unfollow user", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.followAccount(users[1].id);
expect(ok).toBe(true);
const { ok: ok2 } = await client.unfollowAccount(users[1].id);
expect(ok2).toBe(true);
const { ok: ok3, data } = await client.getAccount(users[1].id);
expect(ok3).toBe(true);
expect(data.followers_count).toBe(0);
const { ok: ok4, data: data4 } = await client.getAccount(users[0].id);
expect(ok4).toBe(true);
expect(data4.following_count).toBe(0);
});
test("should return 200 if user already followed", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.followAccount(users[1].id);
expect(ok).toBe(true);
});
});

View file

@ -0,0 +1,59 @@
import {
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withUserParam } from "@/api";
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);
},
),
);

View file

@ -0,0 +1,57 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.muteAccount(users[1].id);
expect(ok).toBe(true);
});
// /api/v1/accounts/:id/unmute
describe("/api/v1/accounts/:id/unmute", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.unmuteAccount(users[0].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if user not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.unmuteAccount(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should unmute user", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.unmuteAccount(users[1].id);
expect(ok).toBe(true);
expect(data.muting).toBe(false);
});
test("should return 200 if user already unmuted", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.unmuteAccount(users[1].id);
expect(ok).toBe(true);
expect(data.muting).toBe(false);
});
});

View file

@ -0,0 +1,64 @@
import {
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withUserParam } from "@/api";
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);
},
),
);

View file

@ -0,0 +1,53 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts/:id/unpin", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.unpinAccount(users[1].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if user not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.unpinAccount(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should unpin account", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.pinAccount(users[1].id);
expect(ok).toBe(true);
expect(data.endorsed).toBe(true);
const { ok: ok2, data: data2 } = await client.unpinAccount(users[1].id);
expect(ok2).toBe(true);
expect(data2.endorsed).toBe(false);
});
test("should return 200 if account already unpinned", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.unpinAccount(users[1].id);
expect(ok).toBe(true);
expect(data.endorsed).toBe(false);
});
});

View file

@ -0,0 +1,64 @@
import {
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withUserParam } from "@/api";
export default apiRoute((app) =>
app.post(
"/api/v1/accounts/:id/unpin",
describeRoute({
summary: "Unfeature account from profile",
description:
"Remove the given account from the users 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);
},
),
);

View file

@ -0,0 +1,99 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils.ts";
const { users, deleteUsers } = await getTestUsers(5);
beforeAll(async () => {
// Create followers relationships
await using client = await generateClient(users[0]);
const { ok } = await client.followAccount(users[1].id);
expect(ok).toBe(true);
const { ok: ok2 } = await client.followAccount(users[2].id);
expect(ok2).toBe(true);
const { ok: ok3 } = await client.followAccount(users[3].id);
expect(ok3).toBe(true);
await using client1 = await generateClient(users[1]);
const { ok: ok4 } = await client1.followAccount(users[2].id);
expect(ok4).toBe(true);
const { ok: ok5 } = await client1.followAccount(users[3].id);
expect(ok5).toBe(true);
await using client2 = await generateClient(users[2]);
const { ok: ok6 } = await client2.followAccount(users[3].id);
expect(ok6).toBe(true);
});
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts/familiar_followers", () => {
test("should return 0 familiar followers", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getFamiliarFollowers([users[4].id]);
expect(ok).toBe(true);
expect(data[0].accounts).toBeArrayOfSize(0);
});
test("should return 1 familiar follower", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getFamiliarFollowers([users[2].id]);
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(1);
expect(data[0].id).toBe(users[2].id);
expect(data[0].accounts).toBeArrayOfSize(1);
expect(data[0].accounts[0].id).toBe(users[1].id);
});
test("should return 2 familiar followers", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getFamiliarFollowers([users[3].id]);
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(1);
expect(data[0].id).toBe(users[3].id);
expect(data[0].accounts).toBeArrayOfSize(2);
expect(data[0].accounts[0].id).toBe(users[2].id);
expect(data[0].accounts[1].id).toBe(users[1].id);
});
test("should work with multiple ids", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getFamiliarFollowers([
users[2].id,
users[3].id,
users[4].id,
]);
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(3);
expect(data[0].id).toBe(users[2].id);
expect(data[0].accounts).toBeArrayOfSize(1);
expect(data[0].accounts[0].id).toBe(users[1].id);
expect(data[1].id).toBe(users[3].id);
expect(data[1].accounts).toBeArrayOfSize(2);
expect(data[1].accounts[0].id).toBe(users[2].id);
expect(data[1].accounts[1].id).toBe(users[1].id);
expect(data[2].id).toBe(users[4].id);
expect(data[2].accounts).toBeArrayOfSize(0);
});
});

View file

@ -0,0 +1,105 @@
import {
Account as AccountSchema,
FamiliarFollowers as FamiliarFollowersSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { db, User } 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 { apiRoute, auth, handleZodError, qsQuery } from "@/api";
import { rateLimit } from "../../../../../middlewares/rate-limit.ts";
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),
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");
// 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"
WHERE "SelfFollowing"."ownerId" = ${user.id}
AND "SelfFollowing"."following" = true
AND EXISTS (
SELECT 1 FROM "Relationships" AS "IdsFollowers"
WHERE "IdsFollowers"."subjectId" = ${id}
AND "IdsFollowers"."ownerId" = "Users"."id"
AND "IdsFollowers"."following" = true
)
`)
).map((u) => u.id as string),
),
})),
);
return context.json(
finalUsers.map((u) => ({
...u,
accounts: u.accounts.map((a) => a.toApi()),
})),
200,
);
},
),
);

View file

@ -0,0 +1,52 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts", () => {
test("should return accounts", async () => {
await using client = await generateClient();
const { data, ok } = await client.getAccounts(users.map((u) => u.id));
expect(ok).toBe(true);
expect(data).toEqual(
expect.arrayContaining(
users.map((u) =>
expect.objectContaining({
id: u.id,
username: u.data.username,
acct: u.data.username,
}),
),
),
);
});
test("should skip nonexistent accounts", async () => {
await using client = await generateClient();
const { data, ok } = await client.getAccounts([
...users.map((u) => u.id),
"00000000-0000-0000-0000-000000000000",
]);
expect(ok).toBe(true);
expect(data).toEqual(
expect.arrayContaining(
users.map((u) =>
expect.objectContaining({
id: u.id,
username: u.data.username,
acct: u.data.username,
}),
),
),
);
expect(data).toHaveLength(users.length);
});
});

View file

@ -0,0 +1,226 @@
import { afterEach, describe, expect, test } from "bun:test";
import { db } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import { randomString } from "@/math";
import { generateClient, getSolvedChallenge } from "~/tests/utils";
const username = randomString(10, "hex");
const username2 = randomString(10, "hex");
afterEach(async () => {
await db.delete(Users).where(eq(Users.username, username));
await db.delete(Users).where(eq(Users.username, username2));
});
// /api/v1/statuses
describe("/api/v1/accounts", () => {
test("should create a new account", async () => {
await using client = await generateClient();
const { ok, raw } = await client.registerAccount(
username,
"bob@gamer.com",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok).toBe(true);
expect(raw.headers.get("Content-Type")).not.toContain("json");
});
test("should refuse invalid emails", async () => {
await using client = await generateClient();
const { ok, raw } = await client.registerAccount(
username,
"bob",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok).toBe(false);
expect(raw.status).toBe(422);
});
test("should require a password", async () => {
await using client = await generateClient();
const { ok, raw } = await client.registerAccount(
username,
"bob@gamer.com",
"",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok).toBe(false);
expect(raw.status).toBe(422);
});
test("should not allow a previously registered email", async () => {
await using client = await generateClient();
const { ok } = await client.registerAccount(
username,
"contact@george.com",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok).toBe(true);
const { ok: ok2, raw } = await client.registerAccount(
username2,
"contact@george.com",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok2).toBe(false);
expect(raw.status).toBe(422);
});
test("should not allow a previously registered email (case insensitive)", async () => {
await using client = await generateClient();
const { ok } = await client.registerAccount(
username,
"contact@george.com",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok).toBe(true);
const { ok: ok2, raw } = await client.registerAccount(
username2,
"CONTACT@george.CoM",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok2).toBe(false);
expect(raw.status).toBe(422);
});
test("should not allow invalid usernames (not a-z_0-9)", async () => {
await using client = await generateClient();
const { ok: ok1, raw: raw1 } = await client.registerAccount(
"bob$",
"contact@george.com",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok1).toBe(false);
expect(raw1.status).toBe(422);
const { ok: ok2, raw: raw2 } = await client.registerAccount(
"bob-markey",
"contact@bob.com",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok2).toBe(false);
expect(raw2.status).toBe(422);
const { ok: ok3, raw: raw3 } = await client.registerAccount(
"bob markey",
"contact@bob.com",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok3).toBe(false);
expect(raw3.status).toBe(422);
const { ok: ok4, raw: raw4 } = await client.registerAccount(
"BOB",
"contact@bob.com",
"password",
true,
"en",
"testing",
{
headers: {
"X-Challenge-Solution": await getSolvedChallenge(),
},
},
);
expect(ok4).toBe(false);
expect(raw4.status).toBe(422);
});
});

View file

@ -0,0 +1,424 @@
import { Account as AccountSchema, zBoolean } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { config } from "@versia-server/config";
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 { apiRoute, auth, handleZodError, jsonOrForm, qsQuery } from "@/api";
import { tempmailDomains } from "@/tempmail";
import { rateLimit } from "../../../../middlewares/rate-limit.ts";
const schema = z.object({
username: z.string().openapi({
description: "The desired username for the account",
example: "alice",
}),
email: z.string().toLowerCase().openapi({
description:
"The email address to be used for login. Transformed to lowercase.",
example: "alice@gmail.com",
}),
password: z.string().openapi({
description: "The password to be used for login",
example: "hunter2",
}),
agreement: zBoolean.openapi({
description:
"Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE.",
example: true,
}),
locale: z.string().openapi({
description:
"The language of the confirmation email that will be sent. ISO 639-1 code.",
example: "en",
}),
reason: z.string().optional().openapi({
description:
"If registrations require manual approval, this text will be reviewed by moderators.",
}),
});
export default apiRoute((app) => {
app.get(
"/api/v1/accounts",
describeRoute({
summary: "Get multiple accounts",
description: "View information about multiple profiles.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#index",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Account records for the requested confirmed and approved accounts. There can be fewer records than requested if the accounts do not exist or are not confirmed.",
content: {
"application/json": {
schema: resolver(z.array(AccountSchema)),
},
},
},
422: ApiError.validationFailed().schema,
},
}),
qsQuery(),
auth({
auth: false,
scopes: [],
challenge: false,
}),
rateLimit(40),
validator(
"query",
z.object({
id: z
.array(AccountSchema.shape.id)
.min(1)
.max(40)
.or(AccountSchema.shape.id.transform((v) => [v]))
.openapi({
description: "The IDs of the Accounts in the database.",
example: [
"f137ce6f-ff5e-4998-b20f-0361ba9be007",
"8424c654-5d03-4a1b-bec8-4e87db811b5d",
],
}),
}),
handleZodError,
),
async (context) => {
const { id: ids } = context.req.valid("query");
// Find accounts by IDs
const accounts = await User.fromIds(ids);
return context.json(
accounts.map((account) => account.toApi()),
200,
);
},
);
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"],
challenge: true,
}),
rateLimit(5),
jsonOrForm(),
validator("json", schema, handleZodError),
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");
}
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 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",
});
}
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,
),
),
);
}
await User.register(username, {
password,
email,
});
return context.text("", 200);
},
);
});

View file

@ -0,0 +1,39 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/lookup
describe("/api/v1/accounts/lookup", () => {
test("should return 200 with users", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.lookupAccount(users[0].data.username);
expect(ok).toBe(true);
expect(data).toEqual(
expect.objectContaining({
id: users[0].id,
username: users[0].data.username,
display_name: users[0].data.displayName,
avatar: expect.any(String),
header: expect.any(String),
}),
);
});
test("should require exact case", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.lookupAccount(
users[0].data.username.toUpperCase(),
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
});

View file

@ -0,0 +1,112 @@
import {
Account as AccountSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Instance, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { config } from "@versia-server/config";
import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, parseUserAddress } from "@/api";
import { rateLimit } from "../../../../../middlewares/rate-limit.ts";
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(60),
validator(
"query",
z.object({
acct: AccountSchema.shape.acct.openapi({
description: "The username or Webfinger address to lookup.",
example: "lexi@beta.versia.social",
}),
}),
handleZodError,
),
async (context) => {
const { acct } = context.req.valid("query");
// 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,
);
}
const account = await User.fromSql(
and(
eq(Users.username, username),
eq(Users.instanceId, instance.id),
),
);
if (account) {
return context.json(account.toApi(), 200);
}
// Fetch from remote instance
const uri = await User.webFinger(username, domain);
if (!uri) {
throw ApiError.accountNotFound();
}
const foundAccount = await User.resolve(uri);
if (foundAccount) {
return context.json(foundAccount.toApi(), 200);
}
throw ApiError.accountNotFound();
},
),
);

View file

@ -0,0 +1,90 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { db } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(5);
beforeAll(async () => {
// user0 should be `locked`
// user1 should follow user0
// user0 should follow user2
await db
.update(Users)
.set({ isLocked: true })
.where(eq(Users.id, users[0].id));
await using client1 = await generateClient(users[1]);
const { ok } = await client1.followAccount(users[0].id);
expect(ok).toBe(true);
await using client0 = await generateClient(users[0]);
const { ok: ok2 } = await client0.followAccount(users[2].id);
expect(ok2).toBe(true);
});
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/relationships
describe("/api/v1/accounts/relationships", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.getRelationships([users[2].id]);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return relationships", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getRelationships([users[2].id]);
expect(ok).toBe(true);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: users[2].id,
following: true,
followed_by: false,
blocking: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false,
endorsed: false,
}),
]),
);
});
test("should be requested_by user1", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getRelationships([users[1].id]);
expect(ok).toBe(true);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({
following: false,
followed_by: false,
blocking: false,
muting: false,
muting_notifications: false,
requested_by: true,
domain_blocking: false,
endorsed: false,
}),
]),
);
});
});

View file

@ -0,0 +1,93 @@
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
RolePermission,
zBoolean,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
import { rateLimit } from "../../../../../middlewares/rate-limit.ts";
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,
scopes: ["read:follows"],
permissions: [RolePermission.ManageOwnFollows],
}),
qsQuery(),
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:
"Whether relationships should be returned for suspended users",
example: false,
}),
}),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
// TODO: Implement with_suspended
const { id: ids } = context.req.valid("query");
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,
);
},
),
);

View file

@ -0,0 +1,30 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/search
describe("/api/v1/accounts/search", () => {
test("should return 200 with users", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.searchAccount(users[0].data.username);
expect(ok).toBe(true);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: users[0].id,
username: users[0].data.username,
display_name: users[0].data.displayName,
avatar: expect.any(String),
header: expect.any(String),
}),
]),
);
});
});

View file

@ -0,0 +1,139 @@
import {
Account as AccountSchema,
RolePermission,
zBoolean,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth, handleZodError, parseUserAddress } from "@/api";
import { rateLimit } from "../../../../../middlewares/rate-limit.ts";
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"],
}),
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,
}),
}),
handleZodError,
),
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 uri = await User.webFinger(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,
);
},
),
);

View file

@ -0,0 +1,47 @@
import { afterAll, describe, expect, test } from "bun:test";
import { config } from "@versia-server/config";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(1);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/update_credentials
describe("/api/v1/accounts/update_credentials", () => {
describe("HTML injection testing", () => {
test("should not allow HTML injection", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.updateCredentials({
note: "Hi! <script>alert('Hello, world!');</script>",
});
expect(ok).toBe(true);
expect(data.note).toBe(
"<p>Hi! &lt;script&gt;alert('Hello, world!');&lt;/script&gt;</p>\n",
);
});
test("should rewrite all image and video src to go through proxy", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.updateCredentials({
note: "<img src='https://example.com/image.jpg'> <video src='https://example.com/video.mp4'> Test!",
});
expect(ok).toBe(true);
expect(data.note).toBe(
// Proxy url is base_url/media/proxy/<base64url encoded url>
`<p><img src="${config.http.base_url}media/proxy/${Buffer.from(
"https://example.com/image.jpg",
).toString("base64url")}"> <video src="${
config.http.base_url
}media/proxy/${Buffer.from(
"https://example.com/video.mp4",
).toString("base64url")}"> Test!</p>\n`,
);
});
});
});

View file

@ -0,0 +1,424 @@
import {
Account as AccountSchema,
RolePermission,
zBoolean,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Emoji, Media, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config";
import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { mergeAndDeduplicate } from "@/lib";
import { sanitizedHtmlStrip } from "@/sanitization";
import { contentToHtml } from "~/classes/functions/status";
import { rateLimit } from "../../../../../middlewares/rate-limit.ts";
export default apiRoute((app) =>
app.patch(
"/api/v1/accounts/update_credentials",
describeRoute({
summary: "Update account credentials",
description: "Update the users 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,
permissions: [RolePermission.ManageOwnAccount],
scopes: ["write:accounts"],
}),
jsonOrForm(),
validator(
"json",
z
.object({
display_name: AccountSchema.shape.display_name
.openapi({
description:
"The display name to use for the profile.",
example: "Lexi",
})
.max(
config.validation.accounts
.max_displayname_characters,
)
.refine(
(s) =>
!config.validation.filters.displayname.some(
(filter) => filter.test(s),
),
"Display name contains blocked words",
),
username: AccountSchema.shape.username
.openapi({
description: "The username to use for the profile.",
example: "lexi",
})
.max(config.validation.accounts.max_username_characters)
.refine(
(s) =>
!config.validation.filters.username.some(
(filter) => filter.test(s),
),
"Username contains blocked words",
)
.refine(
(s) =>
!config.validation.accounts.disallowed_usernames.some(
(u) => u.test(s),
),
"Username is disallowed",
),
note: AccountSchema.shape.note
.openapi({
description:
"The account bio. Markdown is supported.",
})
.max(config.validation.accounts.max_bio_characters)
.refine(
(s) =>
!config.validation.filters.bio.some((filter) =>
filter.test(s),
),
"Bio contains blocked words",
),
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
.unwrap()
.openapi({
description:
"Whether the account should be shown in the profile directory.",
}),
hide_collections: zBoolean.openapi({
description:
"Whether to hide followers and followed accounts.",
}),
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.max(
config.validation.accounts
.max_field_name_characters,
),
value: AccountSchema.shape.fields.element.shape.value.max(
config.validation.accounts
.max_field_value_characters,
),
}),
)
.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,
hide_collections,
indexable,
source,
fields_attributes,
} = context.req.valid("json");
const self = user.data;
if (!self.source) {
self.source = {
fields: [],
privacy: "public",
language: "en",
sensitive: false,
note: "",
};
}
const sanitizedDisplayName = await sanitizedHtmlStrip(
display_name ?? "",
);
if (display_name) {
self.displayName = sanitizedDisplayName;
}
if (note) {
self.source.note = note;
self.note = await contentToHtml(
new VersiaEntities.TextContentFormat({
"text/markdown": {
content: note,
remote: false,
},
}),
);
}
if (source) {
self.source = {
...self.source,
privacy: source.privacy ?? self.source.privacy,
sensitive: source.sensitive ?? self.source.sensitive,
language: source.language ?? self.source.language,
};
}
if (username) {
// Check if username is already taken
const existingUser = await User.fromSql(
and(isNull(Users.instanceId), eq(Users.username, username)),
);
if (existingUser) {
throw new ApiError(422, "Username is already taken");
}
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 !== undefined) {
self.isBot = bot;
}
if (discoverable !== undefined) {
self.isDiscoverable = discoverable;
}
if (hide_collections !== undefined) {
self.isHidingCollections = hide_collections;
}
if (indexable !== undefined) {
self.isIndexable = indexable;
}
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(
new VersiaEntities.TextContentFormat({
"text/markdown": {
content: field.name,
remote: false,
},
}),
undefined,
true,
);
const parsedValue = await contentToHtml(
new VersiaEntities.TextContentFormat({
"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,
isHidingCollections: self.isHidingCollections,
isIndexable: self.isIndexable,
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);
},
),
);

View file

@ -0,0 +1,60 @@
import { afterAll, describe, expect, test } from "bun:test";
import { config } from "@versia-server/config";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(1);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts/verify_credentials", () => {
describe("Authentication", () => {
test("should return 401 when not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.verifyAccountCredentials();
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return user data when authenticated", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.verifyAccountCredentials();
expect(ok).toBe(true);
expect(data.id).toBe(users[0].id);
expect(data.username).toBe(users[0].data.username);
expect(data.acct).toBe(users[0].data.username);
expect(data.display_name).toBe(users[0].data.displayName ?? "");
expect(data.note).toBe(users[0].data.note);
expect(data.url).toBe(
new URL(
`/@${users[0].data.username}`,
config.http.base_url,
).toString(),
);
expect(data.avatar).toBeDefined();
expect(data.avatar_static).toBeDefined();
expect(data.header).toBeDefined();
expect(data.header_static).toBeDefined();
expect(data.locked).toBe(users[0].data.isLocked);
expect(data.bot).toBe(users[0].data.isBot);
expect(data.group).toBe(false);
expect(data.discoverable).toBe(users[0].data.isDiscoverable);
expect(data.noindex).toBe(!users[0].data.isIndexable);
expect(data.moved).toBeNull();
expect(data.suspended).toBe(false);
expect(data.limited).toBe(false);
expect(data.created_at).toBe(
new Date(users[0].data.createdAt).toISOString(),
);
expect(data.last_status_at).toBeNull();
expect(data.statuses_count).toBe(0);
expect(data.followers_count).toBe(0);
expect(data.following_count).toBe(0);
});
});
});

View file

@ -0,0 +1,43 @@
import { Account } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth } from "@/api";
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"],
}),
(context) => {
// TODO: Add checks for disabled/unverified accounts
const { user } = context.get("auth");
return context.json(user.toApi(true), 200);
},
),
);

View file

@ -0,0 +1,82 @@
import {
Application as ApplicationSchema,
CredentialApplication as CredentialApplicationSchema,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Application } from "@versia/kit/db";
import { randomUUIDv7 } from "bun";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, handleZodError, jsonOrForm } from "@/api";
import { randomString } from "@/math";
import { rateLimit } from "../../../../middlewares/rate-limit.ts";
export default apiRoute((app) =>
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({
id: randomUUIDv7(),
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);
},
),
);

View file

@ -0,0 +1,53 @@
import {
Application as ApplicationSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Application } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth } from "@/api";
export default apiRoute((app) =>
app.get(
"/api/v1/apps/verify_credentials",
describeRoute({
summary: "Verify your app works",
description: "Confirm that the apps 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],
}),
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);
},
),
);

View file

@ -0,0 +1,52 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(3);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/blocks", () => {
test("should return 401 when not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.getBlocks();
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return empty array when no blocks", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.getBlocks();
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(0);
});
test("should return blocked users", async () => {
await using client = await generateClient(users[0]);
// Block users[1] and users[2]
await client.blockAccount(users[1].id);
await client.blockAccount(users[2].id);
const { ok, data } = await client.getBlocks();
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(2);
expect(data.map((u) => u.id)).toContain(users[1].id);
expect(data.map((u) => u.id)).toContain(users[2].id);
});
test("should respect limit parameter", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.getBlocks({ limit: 1 });
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(1);
});
});

View file

@ -0,0 +1,112 @@
import {
Account as AccountSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth, handleZodError } from "@/api";
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: {
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],
}),
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: "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,
},
);
},
),
);

View file

@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { generateClient } from "~/tests/utils";
// /api/v1/challenges
describe("/api/v1/challenges", () => {
test("should get a challenge", async () => {
await using client = await generateClient();
const { data, ok } = await client.getChallenge();
expect(ok).toBe(true);
expect(data).toMatchObject({
id: expect.any(String),
algorithm: expect.any(String),
challenge: expect.any(String),
maxnumber: expect.any(Number),
salt: expect.any(String),
signature: expect.any(String),
});
});
});

View file

@ -0,0 +1,54 @@
import { Challenge } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { config } from "@versia-server/config";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth } from "@/api";
import { generateChallenge } from "@/challenges";
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,
}),
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,
);
},
),
);

View file

@ -0,0 +1,124 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { db } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { inArray } from "drizzle-orm";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
// Make user 2 an admin
beforeAll(async () => {
await users[1].update({ isAdmin: true });
// Upload one emoji as admin, then one as each user
await using client1 = await generateClient(users[1]);
const { ok } = await client1.uploadEmoji(
"test1",
new URL("https://cdn.versia.social/logo.webp"),
{
global: true,
},
);
expect(ok).toBe(true);
await using client0 = await generateClient(users[0]);
const { ok: ok2 } = await client0.uploadEmoji(
"test2",
new URL("https://cdn.versia.social/logo.webp"),
);
expect(ok2).toBe(true);
const { ok: ok3 } = await client1.uploadEmoji(
"test3",
new URL("https://cdn.versia.social/logo.webp"),
);
expect(ok3).toBe(true);
});
afterAll(async () => {
await deleteUsers();
await db
.delete(Emojis)
.where(inArray(Emojis.shortcode, ["test1", "test2", "test3"]));
});
describe("/api/v1/custom_emojis", () => {
test("should return all global emojis", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.getInstanceCustomEmojis();
expect(ok).toBe(true);
// Should contain test1 and test2, but not test2
expect(data).toContainEqual(
expect.objectContaining({
shortcode: "test1",
}),
);
expect(data).not.toContainEqual(
expect.objectContaining({
shortcode: "test2",
}),
);
expect(data).toContainEqual(
expect.objectContaining({
shortcode: "test3",
}),
);
});
test("should return all user emojis", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getInstanceCustomEmojis();
expect(ok).toBe(true);
// Should contain test1 and test2, but not test3
expect(data).toContainEqual(
expect.objectContaining({
shortcode: "test1",
}),
);
expect(data).toContainEqual(
expect.objectContaining({
shortcode: "test2",
}),
);
expect(data).not.toContainEqual(
expect.objectContaining({
shortcode: "test3",
}),
);
});
test("should return all global emojis when signed out", async () => {
await using client = await generateClient();
const { data, ok } = await client.getInstanceCustomEmojis();
expect(ok).toBe(true);
// Should contain test1, but not test2 or test3
expect(data).toContainEqual(
expect.objectContaining({
shortcode: "test1",
}),
);
expect(data).not.toContainEqual(
expect.objectContaining({
shortcode: "test2",
}),
);
expect(data).not.toContainEqual(
expect.objectContaining({
shortcode: "test3",
}),
);
});
});

View file

@ -0,0 +1,60 @@
import {
CustomEmoji as CustomEmojiSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth } from "@/api";
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],
}),
async (context) => {
const { user } = context.get("auth");
const emojis = await Emoji.manyFromSql(
and(
isNull(Emojis.instanceId),
or(
isNull(Emojis.ownerId),
user ? eq(Emojis.ownerId, user.id) : undefined,
),
),
);
return context.json(
emojis.map((emoji) => emoji.toApi()),
200,
);
},
),
);

View file

@ -0,0 +1,127 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { db } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { inArray } from "drizzle-orm";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
let id = "";
// Make user 2 an admin
beforeAll(async () => {
await users[1].update({ isAdmin: true });
// Create an emoji
await using client = await generateClient(users[1]);
const { data, ok } = await client.uploadEmoji(
"test",
new URL("https://cdn.versia.social/logo.webp"),
);
expect(ok).toBe(true);
id = data.id;
});
afterAll(async () => {
await deleteUsers();
await db
.delete(Emojis)
.where(inArray(Emojis.shortcode, ["test", "test2", "test3", "test4"]));
});
// /api/v1/emojis/:id (PATCH, DELETE, GET)
describe("/api/v1/emojis/:id", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.getEmoji(id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 404 if emoji does not exist", async () => {
await using client = await generateClient(users[1]);
const { ok, raw } = await client.getEmoji(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should not work if the user is trying to update an emoji they don't own", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.updateEmoji(id, {
shortcode: "test2",
});
expect(ok).toBe(false);
expect(raw.status).toBe(403);
});
test("should return the emoji", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.getEmoji(id);
expect(ok).toBe(true);
expect(data.shortcode).toBe("test");
});
test("should update the emoji", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.updateEmoji(id, {
shortcode: "test2",
});
expect(ok).toBe(true);
expect(data.shortcode).toBe("test2");
});
test("should update the emoji with another url, but keep the shortcode", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.updateEmoji(id, {
image: new URL(
"https://avatars.githubusercontent.com/u/30842467?v=4",
),
});
expect(ok).toBe(true);
expect(data.shortcode).toBe("test2");
expect(data.url).toContain("/media/proxy/");
});
test("should update the emoji to be non-global", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.updateEmoji(id, {
global: false,
});
expect(ok).toBe(true);
expect(data.global).toBe(false);
// Check if the other user can see it
await using client2 = await generateClient(users[0]);
const { data: data2, ok: ok2 } =
await client2.getInstanceCustomEmojis();
expect(ok2).toBe(true);
expect(data2).not.toContainEqual(expect.objectContaining({ id }));
});
test("should delete the emoji", async () => {
await using client = await generateClient(users[1]);
const { ok } = await client.deleteEmoji(id);
expect(ok).toBe(true);
});
});

View file

@ -0,0 +1,273 @@
import {
CustomEmoji as CustomEmojiSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { config } from "@versia-server/config";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import {
apiRoute,
auth,
handleZodError,
jsonOrForm,
withEmojiParam,
} from "@/api";
import { mimeLookup } from "@/content_types";
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
.max(config.validation.emojis.max_shortcode_characters)
.refine(
(s) =>
!config.validation.filters.emoji_shortcode.some(
(filter) => filter.test(s),
),
"Shortcode contains blocked words",
),
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
.unwrap()
.max(
config.validation.emojis.max_description_characters,
)
.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);
},
);
});

View file

@ -0,0 +1,147 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { db } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { inArray } from "drizzle-orm";
import sharp from "sharp";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(3);
// Make user 2 an admin
beforeAll(async () => {
await users[1].update({ isAdmin: true });
});
afterAll(async () => {
await deleteUsers();
await db
.delete(Emojis)
.where(inArray(Emojis.shortcode, ["test1", "test2", "test3", "test4"]));
});
const createImage = async (name: string): Promise<File> => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
return new File([inputBuffer], name, {
type: "image/png",
});
};
describe("/api/v1/emojis", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.uploadEmoji(
"test",
new URL("https://cdn.versia.social/logo.webp"),
);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
describe("Admin tests", () => {
test("should upload a file and create an emoji", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.uploadEmoji(
"test1",
await createImage("test.png"),
{
global: true,
},
);
expect(ok).toBe(true);
expect(data.shortcode).toBe("test1");
expect(data.url).toContain("/media/");
});
test("should try to upload a non-image", async () => {
await using client = await generateClient(users[1]);
const { ok, raw } = await client.uploadEmoji(
"test2",
new File(["test"], "test.txt"),
);
expect(ok).toBe(false);
expect(raw.status).toBe(422);
});
test("should upload an emoji by url", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.uploadEmoji(
"test3",
new URL("https://cdn.versia.social/logo.webp"),
);
expect(ok).toBe(true);
expect(data.shortcode).toBe("test3");
expect(data.url).toContain("/media/proxy");
});
test("should fail when uploading an already existing emoji", async () => {
await using client = await generateClient(users[1]);
const { ok, raw } = await client.uploadEmoji(
"test1",
await createImage("test-image.png"),
);
expect(ok).toBe(false);
expect(raw.status).toBe(422);
});
});
describe("User tests", () => {
test("should upload a file and create an emoji", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.uploadEmoji(
"test4",
await createImage("test-image.png"),
);
expect(ok).toBe(true);
expect(data.shortcode).toBe("test4");
expect(data.url).toContain("/media/");
});
test("should fail when uploading an already existing global emoji", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.uploadEmoji(
"test1",
await createImage("test-image.png"),
);
expect(ok).toBe(false);
expect(raw.status).toBe(422);
});
test("should create an emoji as another user with the same shortcode", async () => {
await using client = await generateClient(users[2]);
const { data, ok } = await client.uploadEmoji(
"test4",
await createImage("test-image.png"),
);
expect(ok).toBe(true);
expect(data.shortcode).toBe("test4");
expect(data.url).toContain("/media/");
});
});
});

View file

@ -0,0 +1,152 @@
import {
CustomEmoji as CustomEmojiSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Emoji, Media } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { config } from "@versia-server/config";
import { randomUUIDv7 } from "bun";
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 { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { mimeLookup } from "@/content_types";
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: [
RolePermission.ManageOwnEmojis,
RolePermission.ViewEmojis,
],
}),
jsonOrForm(),
validator(
"json",
z.object({
shortcode: CustomEmojiSchema.shape.shortcode
.max(config.validation.emojis.max_shortcode_characters)
.refine(
(s) =>
!config.validation.filters.emoji_shortcode.some(
(filter) => filter.test(s),
),
"Shortcode contains blocked words",
),
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
.unwrap()
.max(config.validation.emojis.max_description_characters)
.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({
id: randomUUIDv7(),
shortcode,
mediaId: media.id,
visibleInPicker: true,
ownerId: global ? null : user.id,
category,
});
return context.json(emoji.toApi(), 201);
},
),
);

View file

@ -0,0 +1,110 @@
import { RolePermission, Status as StatusSchema } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth, handleZodError } from "@/api";
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: {
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],
}),
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: "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,
},
);
},
),
);

View file

@ -0,0 +1,82 @@
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship, User } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError } from "@/api";
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.remote) {
// Federate follow accept
await user.acceptFollowRequest(account);
}
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,83 @@
import {
Account as AccountSchema,
Relationship as RelationshipSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Relationship, User } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError } from "@/api";
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.remote) {
// Federate follow reject
await user.rejectFollowRequest(account);
}
return context.json(foundRelationship.toApi(), 200);
},
),
);

View file

@ -0,0 +1,114 @@
import {
Account as AccountSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth, handleZodError } from "@/api";
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: {
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],
}),
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: "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: 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,
},
);
},
),
);

View file

@ -0,0 +1,29 @@
import { config } from "@versia-server/config";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute } from "@/api";
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);
},
),
);

View file

@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import { generateClient } from "~/tests/utils";
// /api/v1/instance/extended_description
describe("/api/v1/instance/extended_description", () => {
test("should return extended description", async () => {
await using client = await generateClient();
const { data, ok } = await client.getInstanceExtendedDescription();
expect(ok).toBe(true);
expect(data).toEqual({
updated_at: new Date(0).toISOString(),
content:
'<p>This is a <a href="https://versia.pub">Versia</a> server with the default extended description.</p>\n',
});
});
});

View file

@ -0,0 +1,47 @@
import { ExtendedDescription as ExtendedDescriptionSchema } from "@versia/client/schemas";
import { config } from "@versia-server/config";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute } from "@/api";
import { markdownParse } from "~/classes/functions/status";
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,
);
},
),
);

View file

@ -0,0 +1,139 @@
import { InstanceV1 as InstanceV1Schema } from "@versia/client/schemas";
import { Instance, Note, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { config } from "@versia-server/config";
import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import type { z } from "zod";
import { apiRoute } from "@/api";
import { markdownParse } from "~/classes/functions/status";
import manifest from "~/package.json" with { type: "json" };
export default apiRoute((app) =>
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),
},
},
},
},
}),
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?.proxied ?? 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,
id: p.id,
})) ?? [],
},
contact_account: (contactAccount as User)?.toApi(),
} satisfies z.infer<typeof InstanceV1Schema>);
},
),
);

View file

@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import { generateClient } from "~/tests/utils";
// /api/v1/instance/privacy_policy
describe("/api/v1/instance/privacy_policy", () => {
test("should return privacy policy", async () => {
await using client = await generateClient();
const { data, ok } = await client.getInstancePrivacyPolicy();
expect(ok).toBe(true);
expect(data).toEqual({
updated_at: new Date(0).toISOString(),
// This instance has not provided any privacy policy.
content:
"<p>This instance has not provided any privacy policy.</p>\n",
});
});
});

View file

@ -0,0 +1,43 @@
import { PrivacyPolicy as PrivacyPolicySchema } from "@versia/client/schemas";
import { config } from "@versia-server/config";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute } from "@/api";
import { markdownParse } from "~/classes/functions/status";
export default apiRoute((app) =>
app.get(
"/api/v1/instance/privacy_policy",
describeRoute({
summary: "View privacy policy",
description: "Obtain the contents of this servers 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,
});
},
),
);

View file

@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { config } from "@versia-server/config";
import { generateClient } from "~/tests/utils";
// /api/v1/instance/rules
describe("/api/v1/instance/rules", () => {
test("should return rules", async () => {
await using client = await generateClient();
const { data, ok } = await client.getRules();
expect(ok).toBe(true);
expect(data).toEqual(
config.instance.rules.map((r, index) => ({
id: String(index),
text: r.text,
hint: r.hint,
})),
);
});
});

View file

@ -0,0 +1,39 @@
import { Rule as RuleSchema } from "@versia/client/schemas";
import { config } from "@versia-server/config";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute } from "@/api";
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,
})),
);
},
),
);

View file

@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import { generateClient } from "~/tests/utils";
// /api/v1/instance/terms_of_service
describe("/api/v1/instance/terms_of_service", () => {
test("should return terms of service", async () => {
await using client = await generateClient();
const { data, ok } = await client.getInstanceTermsOfService();
expect(ok).toBe(true);
expect(data).toEqual({
updated_at: new Date(0).toISOString(),
// This instance has not provided any terms of service.
content:
"<p>This instance has not provided any terms of service.</p>\n",
});
});
});

View file

@ -0,0 +1,44 @@
import { TermsOfService as TermsOfServiceSchema } from "@versia/client/schemas";
import { config } from "@versia-server/config";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute } from "@/api";
import { markdownParse } from "~/classes/functions/status";
export default apiRoute((app) =>
app.get(
"/api/v1/instance/terms_of_service",
describeRoute({
summary: "View terms of service",
description:
"Obtain the contents of this servers 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,
});
},
),
);

View file

@ -0,0 +1,64 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(1);
const timeline = await getTestStatuses(10, users[0]);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/markers
describe("/api/v1/markers", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.getMarkers(["home", "notifications"]);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return empty markers", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getMarkers(["home", "notifications"]);
expect(ok).toBe(true);
expect(data).toEqual({});
});
test("should create markers", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.saveMarkers({
home: {
last_read_id: timeline[0].id,
},
});
expect(ok).toBe(true);
expect(data).toEqual({
home: {
last_read_id: timeline[0].id,
updated_at: expect.any(String),
version: 1,
},
});
});
test("should return markers", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getMarkers(["home", "notifications"]);
expect(ok).toBe(true);
expect(data).toEqual({
home: {
last_read_id: timeline[0].id,
updated_at: expect.any(String),
version: 1,
},
});
});
});

View file

@ -0,0 +1,251 @@
import {
Marker as MarkerSchema,
Notification as NotificationSchema,
RolePermission,
Status as StatusSchema,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { db } from "@versia/kit/db";
import { Markers } from "@versia/kit/tables";
import { randomUUIDv7 } from "bun";
import { and, eq, type SQL } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError } from "@/api";
const MarkerResponseSchema = z.object({
notifications: MarkerSchema.optional(),
home: MarkerSchema.optional(),
});
export default apiRoute((app) => {
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);
}
const markers: z.infer<typeof MarkerResponseSchema> = {
home: undefined,
notifications: undefined,
};
if (timeline.includes("home")) {
const found = await db.query.Markers.findFirst({
where: (marker): SQL | undefined =>
and(
eq(marker.userId, user.id),
eq(marker.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): 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({
id: randomUUIDv7(),
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({
id: randomUUIDv7(),
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);
},
);
});

View file

@ -0,0 +1,160 @@
import {
Attachment as AttachmentSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Media } from "@versia/kit/db";
import { config } from "@versia-server/config";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError } from "@/api";
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 MediaAttachments 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
.unwrap()
.max(config.validation.media.max_description_characters)
.optional(),
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);
},
);
});

View file

@ -0,0 +1,99 @@
import {
Attachment as AttachmentSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Media } from "@versia/kit/db";
import { config } from "@versia-server/config";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError } from "@/api";
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],
}),
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
.unwrap()
.max(config.validation.media.max_description_characters)
.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",
},
}),
}),
handleZodError,
),
async (context) => {
const { file, thumbnail, description } = context.req.valid("form");
const attachment = await Media.fromFile(file, {
thumbnail,
description: description ?? undefined,
});
return context.json(attachment.toApi(), 200);
},
),
);

View file

@ -0,0 +1,54 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(3);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.muteAccount(users[1].id);
expect(ok).toBe(true);
});
// /api/v1/mutes
describe("/api/v1/mutes", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.getMutes();
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return mutes", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getMutes();
expect(ok).toBe(true);
expect(data).toEqual([
expect.objectContaining({
id: users[1].id,
}),
]);
});
test("should return mutes after unmute", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.unmuteAccount(users[1].id);
expect(ok).toBe(true);
const { data, ok: ok2 } = await client.getMutes();
expect(ok2).toBe(true);
expect(data).toEqual([]);
});
});

View file

@ -0,0 +1,111 @@
import {
Account as AccountSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth, handleZodError } from "@/api";
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: {
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],
}),
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: "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,
},
);
},
),
);

View file

@ -0,0 +1,67 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Notification } from "@versia/client/schemas";
import type { z } from "zod";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
let notifications: z.infer<typeof Notification>[] = [];
// Create some test notifications: follow, favourite, reblog, mention
beforeAll(async () => {
await using client0 = await generateClient(users[0]);
await using client1 = await generateClient(users[1]);
const { ok } = await client1.followAccount(users[0].id);
expect(ok).toBe(true);
const { data } = await client0.getNotifications();
expect(data).toBeArrayOfSize(1);
notifications = data;
});
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/notifications/:id/dismiss", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.dismissNotification(
notifications[0].id,
);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should dismiss notification", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.dismissNotification(notifications[0].id);
expect(ok).toBe(true);
});
test("should not display dismissed notification", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getNotifications();
expect(ok).toBe(true);
expect(data).toBeArrayOfSize(0);
});
test("should not be able to dismiss other user's notifications", async () => {
await using client = await generateClient(users[1]);
const { ok, raw } = await client.dismissNotification(
notifications[0].id,
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
});

View file

@ -0,0 +1,61 @@
import {
Notification as NotificationSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Notification } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError } from "@/api";
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);
},
),
);

View file

@ -0,0 +1,80 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Notification } from "@versia/client/schemas";
import type { z } from "zod";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
let notifications: z.infer<typeof Notification>[] = [];
beforeAll(async () => {
await using client0 = await generateClient(users[0]);
await using client1 = await generateClient(users[1]);
const { ok } = await client1.followAccount(users[0].id);
expect(ok).toBe(true);
const { data } = await client0.getNotifications();
expect(data).toBeArrayOfSize(1);
notifications = data;
});
afterAll(async () => {
await deleteUsers();
});
// /api/v1/notifications/:id
describe("/api/v1/notifications/:id", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.getNotification(notifications[0].id);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 422 if ID is invalid", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.getNotification("invalid");
expect(ok).toBe(false);
expect(raw.status).toBe(422);
});
test("should return 404 if notification not found", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.getNotification(
"00000000-0000-0000-0000-000000000000",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
test("should return notification", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.getNotification(notifications[0].id);
expect(ok).toBe(true);
expect(data).toBeDefined();
expect(data.id).toBe(notifications[0].id);
expect(data.type).toBe("follow");
expect(data.account).toBeDefined();
expect(data.account?.id).toBe(users[1].id);
});
test("should not be able to view other user's notifications", async () => {
await using client = await generateClient(users[1]);
const { ok, raw } = await client.getNotification(notifications[0].id);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
});

View file

@ -0,0 +1,62 @@
import {
Notification as NotificationSchema,
RolePermission,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { Notification } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError } from "@/api";
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);
},
),
);

View file

@ -0,0 +1,47 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
// Create some test notifications: follow, favourite, reblog, mention
beforeAll(async () => {
await using client1 = await generateClient(users[1]);
await using client0 = await generateClient(users[0]);
const { ok } = await client1.followAccount(users[0].id);
expect(ok).toBe(true);
const { data, ok: ok2 } = await client0.getNotifications();
expect(ok2).toBe(true);
expect(data).toBeArrayOfSize(1);
});
afterAll(async () => {
await deleteUsers();
});
// /api/v1/notifications/clear
describe("/api/v1/notifications/clear", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.dismissNotifications();
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should clear notifications", async () => {
await using client = await generateClient(users[0]);
const { ok } = await client.dismissNotifications();
expect(ok).toBe(true);
const { data: newNotifications } = await client.getNotifications();
expect(newNotifications).toBeArrayOfSize(0);
});
});

View file

@ -0,0 +1,36 @@
import { RolePermission } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { describeRoute } from "hono-openapi";
import { apiRoute, auth } from "@/api";
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"],
}),
async (context) => {
const { user } = context.get("auth");
await user.clearAllNotifications();
return context.text("", 200);
},
),
);

View file

@ -0,0 +1,65 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
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);
const statuses = await getTestStatuses(5, users[0]);
let notifications: z.infer<typeof Notification>[] = [];
// Create some test notifications
beforeAll(async () => {
await using client0 = await generateClient(users[0]);
await using client1 = await generateClient(users[1]);
const { ok } = await client1.followAccount(users[0].id);
expect(ok).toBe(true);
for (const i of [0, 1, 2, 3]) {
await client1.favouriteStatus(statuses[i].id);
}
const { data } = await client0.getNotifications();
expect(data).toBeArrayOfSize(5);
notifications = data;
});
afterAll(async () => {
await deleteUsers();
});
// /api/v1/notifications/destroy_multiple
describe("/api/v1/notifications/destroy_multiple", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.dismissMultipleNotifications(
notifications.slice(1).map((n) => n.id),
);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should dismiss notifications", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.dismissMultipleNotifications(
notifications.slice(1).map((n) => n.id),
);
expect(ok).toBe(true);
expect(raw.status).toBe(200);
});
test("should not display dismissed notifications", async () => {
await using client = await generateClient(users[0]);
const { data } = await client.getNotifications();
expect(data).toBeArrayOfSize(1);
expect(data[0].id).toBe(notifications[0].id);
});
});

View file

@ -0,0 +1,44 @@
import { RolePermission } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, qsQuery } from "@/api";
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"],
}),
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);
},
),
);

View file

@ -0,0 +1,103 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
const timeline = (await getTestStatuses(5, users[0])).toReversed();
// Create some test notifications: follow, favourite, reblog, mention
beforeAll(async () => {
await using client = await generateClient(users[1]);
const { ok } = await client.followAccount(users[0].id);
expect(ok).toBe(true);
const { ok: ok2 } = await client.favouriteStatus(timeline[0].id);
expect(ok2).toBe(true);
const { ok: ok3 } = await client.reblogStatus(timeline[0].id);
expect(ok3).toBe(true);
const { ok: ok4 } = await client.postStatus(
`@${users[0].data.username} test mention`,
{
visibility: "direct",
local_only: true,
},
);
expect(ok4).toBe(true);
});
afterAll(async () => {
await deleteUsers();
});
// /api/v1/notifications
describe("/api/v1/notifications", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.getNotifications();
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should return 200 with notifications", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.getNotifications();
expect(ok).toBe(true);
expect(data.length).toBe(4);
for (const [index, notification] of data.entries()) {
expect(notification.account).toBeDefined();
expect(notification.account?.id).toBe(users[1].id);
expect(notification.created_at).toBeDefined();
expect(notification.id).toBeDefined();
expect(notification.type).toBeDefined();
expect(notification.type).toBe(
["follow", "favourite", "reblog", "mention"].toReversed()[
index
] as "follow" | "favourite" | "reblog" | "mention",
);
}
});
test("should not return notifications with filtered keywords", async () => {
await using client = await generateClient(users[0]);
const { data: filter, ok } = await client.createFilter(
["notifications"],
"Test Filter",
"hide",
{
keywords_attributes: [
{
keyword: timeline[0].content.slice(4, 20),
whole_word: false,
},
],
},
);
expect(ok).toBe(true);
const { data: notifications } = await client.getNotifications();
expect(notifications.length).toBe(3);
// There should be no element with a status with id of timeline[0].id
expect(notifications).not.toContainEqual(
expect.objectContaining({
status: expect.objectContaining({ id: timeline[0].id }),
}),
);
// Delete filter
const { ok: ok2 } = await client.deleteFilter(filter.id);
expect(ok2).toBe(true);
});
});

View file

@ -0,0 +1,164 @@
import {
Account as AccountSchema,
Notification as NotificationSchema,
RolePermission,
zBoolean,
} from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
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 { apiRoute, auth, handleZodError } from "@/api";
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: [
RolePermission.ManageOwnNotifications,
RolePermission.ViewPrivateTimelines,
],
}),
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",
}),
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.",
}),
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");
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 (
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,
},
);
},
),
);

View file

@ -0,0 +1,43 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(2);
let avatarUrl: string;
beforeAll(async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.updateCredentials({
avatar: new URL("https://placehold.co/100x100"),
});
expect(ok).toBe(true);
avatarUrl = data.avatar;
});
afterAll(async () => {
await deleteUsers();
});
describe("POST /api/v1/profile/avatar", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.deleteAvatar();
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should delete avatar", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.deleteAvatar();
expect(ok).toBe(true);
// Avatars are defaulted to a placeholder
expect(data.avatar).not.toBe(avatarUrl);
});
});

View file

@ -0,0 +1,47 @@
import { Account, RolePermission } from "@versia/client/schemas";
import { ApiError } from "@versia/kit";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { apiRoute, auth } from "@/api";
export default apiRoute((app) =>
app.delete(
"/api/v1/profile/avatar",
describeRoute({
summary: "Delete profile avatar",
description:
"Deletes the avatar associated with the users profile.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/profile/#delete-profile-avatar",
},
tags: ["Profile"],
responses: {
200: {
description:
"The avatar was successfully deleted from the users 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"],
}),
async (context) => {
const { user } = context.get("auth");
await user.avatar?.delete();
await user.reload();
return context.json(user.toApi(true), 200);
},
),
);

View file

@ -0,0 +1,41 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(3);
let headerUrl: string;
beforeAll(async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.updateCredentials({
header: new URL("https://placehold.co/100x100"),
});
expect(ok).toBe(true);
headerUrl = data.header;
});
afterAll(async () => {
await deleteUsers();
});
describe("POST /api/v1/profile/header", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.deleteHeader();
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should delete header", async () => {
await using client = await generateClient(users[0]);
const { ok, data } = await client.deleteHeader();
expect(ok).toBe(true);
expect(data.header).not.toBe(headerUrl);
});
});

Some files were not shown because too many files have changed in this diff Show more