mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 13:59:16 +01:00
refactor(api): 🚚 Use api/ for API routes instead of server/api/
This commit is contained in:
parent
dfc0bf4595
commit
3c1b330d4b
143 changed files with 5 additions and 5 deletions
264
api/api/auth/login/index.test.ts
Normal file
264
api/api/auth/login/index.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { randomString } from "@/math";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Applications } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
||||
|
||||
// Create application
|
||||
const application = (
|
||||
await db
|
||||
.insert(Applications)
|
||||
.values({
|
||||
name: "Test Application",
|
||||
clientId: randomString(32, "hex"),
|
||||
secret: "test",
|
||||
redirectUri: "https://example.com",
|
||||
scopes: "read write",
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
await db.delete(Applications).where(eq(Applications.id, application.id));
|
||||
});
|
||||
|
||||
// /api/auth/login
|
||||
describe(meta.route, () => {
|
||||
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 sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
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.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 sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
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.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 sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write&state=abc`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
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.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 sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
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 sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
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 sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
196
api/api/auth/login/index.ts
Normal file
196
api/api/auth/login/index.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
||||
import { redirect } from "@/response";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { SignJWT } from "jose";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 4,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/auth/login",
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
form: z.object({
|
||||
identifier: z
|
||||
.string()
|
||||
.email()
|
||||
.toLowerCase()
|
||||
.or(z.string().toLowerCase()),
|
||||
password: z.string().min(2).max(100),
|
||||
}),
|
||||
query: z.object({
|
||||
scope: z.string().optional(),
|
||||
redirect_uri: z.string().url().optional(),
|
||||
response_type: z.enum([
|
||||
"code",
|
||||
"token",
|
||||
"none",
|
||||
"id_token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"token id_token",
|
||||
"code token id_token",
|
||||
]),
|
||||
client_id: z.string(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
||||
prompt: z
|
||||
.enum(["none", "login", "consent", "select_account"])
|
||||
.optional()
|
||||
.default("none"),
|
||||
max_age: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.default(60 * 60 * 24 * 7),
|
||||
}),
|
||||
};
|
||||
|
||||
const returnError = (query: object, error: string, description: string) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// Add all data that is not undefined except email and password
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (key !== "email" && key !== "password" && value !== undefined) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
searchParams.append("error", error);
|
||||
searchParams.append("error_description", description);
|
||||
|
||||
return redirect(
|
||||
new URL(
|
||||
`${config.frontend.routes.login}?${searchParams.toString()}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("form", schemas.form, handleZodError),
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
async (context) => {
|
||||
if (config.oidc.forced) {
|
||||
return returnError(
|
||||
context.req.query(),
|
||||
"invalid_request",
|
||||
"Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
|
||||
);
|
||||
}
|
||||
|
||||
const { identifier, password } = context.req.valid("form");
|
||||
const { client_id } = context.req.valid("query");
|
||||
|
||||
// Find user
|
||||
const user = await User.fromSql(
|
||||
or(
|
||||
eq(Users.email, identifier.toLowerCase()),
|
||||
eq(Users.username, identifier.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
!(
|
||||
user &&
|
||||
(await Bun.password.verify(
|
||||
password,
|
||||
user.data.password || "",
|
||||
))
|
||||
)
|
||||
) {
|
||||
return returnError(
|
||||
context.req.query(),
|
||||
"invalid_grant",
|
||||
"Invalid identifier or password",
|
||||
);
|
||||
}
|
||||
|
||||
if (user.data.passwordResetToken) {
|
||||
return 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(config.oidc.jwt_key.split(";")[0], "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
// Generate JWT
|
||||
const jwt = await new SignJWT({
|
||||
sub: user.id,
|
||||
iss: new URL(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 db.query.Applications.findFirst({
|
||||
where: (app, { eq }) => eq(app.clientId, client_id),
|
||||
});
|
||||
|
||||
if (!application) {
|
||||
return context.json({ error: "Invalid application" }, 400);
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
application: application.name,
|
||||
});
|
||||
|
||||
if (application.website) {
|
||||
searchParams.append("website", application.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
|
||||
return redirect(
|
||||
`${config.frontend.routes.consent}?${searchParams.toString()}`,
|
||||
302,
|
||||
{
|
||||
"Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${
|
||||
60 * 60
|
||||
}`,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
107
api/api/auth/mastodon-login/index.ts
Normal file
107
api/api/auth/mastodon-login/index.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
||||
import { randomString } from "@/math";
|
||||
import { response } from "@/response";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { TokenType } from "~/classes/functions/token";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Tokens, Users } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 4,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/auth/mastodon-logout",
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
form: z.object({
|
||||
user: z.object({
|
||||
email: z.string().email().toLowerCase(),
|
||||
password: z.string().min(2).max(100),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mastodon-FE login route
|
||||
*/
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("form", schemas.form, handleZodError),
|
||||
async (context) => {
|
||||
const {
|
||||
user: { email, password },
|
||||
} = context.req.valid("form");
|
||||
|
||||
const redirectToLogin = (error: string) =>
|
||||
response(null, 302, {
|
||||
Location: `/auth/sign_in?${new URLSearchParams({
|
||||
...context.req.query,
|
||||
error: encodeURIComponent(error),
|
||||
}).toString()}`,
|
||||
});
|
||||
|
||||
const user = await User.fromSql(eq(Users.email, email));
|
||||
|
||||
if (
|
||||
!(
|
||||
user &&
|
||||
(await Bun.password.verify(
|
||||
password,
|
||||
user.data.password || "",
|
||||
))
|
||||
)
|
||||
) {
|
||||
return redirectToLogin("Invalid email or password");
|
||||
}
|
||||
|
||||
if (user.data.passwordResetToken) {
|
||||
return response(null, 302, {
|
||||
Location: new URL(
|
||||
`${
|
||||
config.frontend.routes.password_reset
|
||||
}?${new URLSearchParams({
|
||||
token: user.data.passwordResetToken ?? "",
|
||||
login_reset: "true",
|
||||
}).toString()}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
const code = randomString(32, "hex");
|
||||
const accessToken = randomString(64, "base64url");
|
||||
|
||||
await db.insert(Tokens).values({
|
||||
accessToken,
|
||||
code: code,
|
||||
scope: "read write follow push",
|
||||
tokenType: TokenType.Bearer,
|
||||
applicationId: null,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// One week from now
|
||||
const maxAge = String(60 * 60 * 24 * 7);
|
||||
|
||||
// Redirect to home
|
||||
return response(null, 303, {
|
||||
Location: "/",
|
||||
"Set-Cookie": `_session_id=${accessToken}; Domain=${
|
||||
new URL(config.http.base_url).hostname
|
||||
}; SameSite=Lax; Path=/; HttpOnly; Max-Age=${maxAge}`,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
31
api/api/auth/mastodon-logout/index.ts
Normal file
31
api/api/auth/mastodon-logout/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { apiRoute, applyConfig } from "@/api";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 4,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/auth/mastodon-logout",
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mastodon-FE logout route
|
||||
*/
|
||||
export default apiRoute((app) =>
|
||||
app.on(meta.allowedMethods, meta.route, () => {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
Location: "/",
|
||||
"Set-Cookie": `_session_id=; Domain=${
|
||||
new URL(config.http.base_url).hostname
|
||||
}; SameSite=Lax; Path=/; HttpOnly; Max-Age=0; Expires=${new Date().toUTCString()}`,
|
||||
},
|
||||
status: 303,
|
||||
});
|
||||
}),
|
||||
);
|
||||
73
api/api/auth/redirect/index.ts
Normal file
73
api/api/auth/redirect/index.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Applications, Tokens } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 4,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/auth/redirect",
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
redirect_uri: z.string().url(),
|
||||
client_id: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* OAuth Code flow
|
||||
*/
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
async (context) => {
|
||||
const { redirect_uri, client_id, code } =
|
||||
context.req.valid("query");
|
||||
|
||||
const redirectToLogin = (error: string) =>
|
||||
Response.redirect(
|
||||
`${config.frontend.routes.login}?${new URLSearchParams({
|
||||
...context.req.query,
|
||||
error: encodeURIComponent(error),
|
||||
}).toString()}`,
|
||||
302,
|
||||
);
|
||||
|
||||
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 Response.redirect(`${redirect_uri}?code=${code}`, 302);
|
||||
},
|
||||
),
|
||||
);
|
||||
146
api/api/auth/reset/index.test.ts
Normal file
146
api/api/auth/reset/index.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { randomString } from "@/math";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Applications } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
||||
const token = randomString(32, "hex");
|
||||
const newPassword = randomString(16, "hex");
|
||||
|
||||
// Create application
|
||||
const application = (
|
||||
await db
|
||||
.insert(Applications)
|
||||
.values({
|
||||
name: "Test Application",
|
||||
clientId: randomString(32, "hex"),
|
||||
secret: "test",
|
||||
redirectUri: "https://example.com",
|
||||
scopes: "read write",
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
await db.delete(Applications).where(eq(Applications.id, application.id));
|
||||
});
|
||||
|
||||
// /api/auth/reset
|
||||
describe(meta.route, () => {
|
||||
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 sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
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 sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
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 sendTestRequest(
|
||||
new Request(new URL("/api/auth/reset", config.http.base_url), {
|
||||
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 sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
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.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=[^;]+;/);
|
||||
});
|
||||
});
|
||||
72
api/api/auth/reset/index.ts
Normal file
72
api/api/auth/reset/index.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { apiRoute, applyConfig, handleZodError } from "@/api";
|
||||
import { response } from "@/response";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 4,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/auth/reset",
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
form: z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(3).max(100),
|
||||
}),
|
||||
};
|
||||
|
||||
const returnError = (token: string, error: string, description: string) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.append("error", error);
|
||||
searchParams.append("error_description", description);
|
||||
searchParams.append("token", token);
|
||||
|
||||
return response(null, 302, {
|
||||
Location: new URL(
|
||||
`${
|
||||
config.frontend.routes.password_reset
|
||||
}?${searchParams.toString()}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
});
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("form", schemas.form, handleZodError),
|
||||
async (context) => {
|
||||
const { token, password } = context.req.valid("form");
|
||||
|
||||
const user = await User.fromSql(
|
||||
eq(Users.passwordResetToken, token),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return returnError(token, "invalid_token", "Invalid token");
|
||||
}
|
||||
|
||||
await user.update({
|
||||
password: await Bun.password.hash(password),
|
||||
passwordResetToken: null,
|
||||
});
|
||||
|
||||
return response(null, 302, {
|
||||
Location: `${config.frontend.routes.password_reset}?success=true`,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
92
api/api/v1/accounts/:id/block.test.ts
Normal file
92
api/api/v1/accounts/:id/block.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import type { Relationship as ApiRelationship } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./block";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/accounts/:id/block
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 404 if user not found", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(
|
||||
":id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should block user", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const relationship = (await response.json()) as ApiRelationship;
|
||||
expect(relationship.blocking).toBe(true);
|
||||
});
|
||||
|
||||
test("should return 200 if user already blocked", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const relationship = (await response.json()) as ApiRelationship;
|
||||
expect(relationship.blocking).toBe(true);
|
||||
});
|
||||
});
|
||||
67
api/api/v1/accounts/:id/block.ts
Normal file
67
api/api/v1/accounts/:id/block.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/block",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:blocks"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnBlocks,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (!foundRelationship.data.blocking) {
|
||||
await foundRelationship.update({
|
||||
blocking: true,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
102
api/api/v1/accounts/:id/follow.test.ts
Normal file
102
api/api/v1/accounts/:id/follow.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import type { Relationship as ApiRelationship } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./follow";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/accounts/:id/follow
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 404 if user not found", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(
|
||||
":id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should follow user", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const relationship = (await response.json()) as ApiRelationship;
|
||||
expect(relationship.following).toBe(true);
|
||||
});
|
||||
|
||||
test("should return 200 if user already followed", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const relationship = (await response.json()) as ApiRelationship;
|
||||
expect(relationship.following).toBe(true);
|
||||
});
|
||||
});
|
||||
82
api/api/v1/accounts/:id/follow.ts
Normal file
82
api/api/v1/accounts/:id/follow.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import ISO6391 from "iso-639-1";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/follow",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:follows"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnFollows,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
json: z
|
||||
.object({
|
||||
reblogs: z.coerce.boolean().optional(),
|
||||
notify: z.coerce.boolean().optional(),
|
||||
languages: z
|
||||
.array(z.enum(ISO6391.getAllCodes() as [string, ...string[]]))
|
||||
.optional(),
|
||||
})
|
||||
.optional()
|
||||
.default({ reblogs: true, notify: false, languages: [] }),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
const { reblogs, notify, languages } = context.req.valid("json");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
let relationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (!relationship.data.following) {
|
||||
relationship = await user.followRequest(otherUser, {
|
||||
reblogs,
|
||||
notify,
|
||||
languages,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(relationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
101
api/api/v1/accounts/:id/followers.test.ts
Normal file
101
api/api/v1/accounts/:id/followers.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Account as ApiAccount } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./followers";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// Follow user
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[1].id}/follow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
// /api/v1/accounts/:id/followers
|
||||
describe(meta.route, () => {
|
||||
test("should return 200 with followers", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiAccount[];
|
||||
|
||||
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 () => {
|
||||
// Unfollow user
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[1].id}/unfollow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const response2 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response2.status).toBe(200);
|
||||
|
||||
const data = (await response2.json()) as ApiAccount[];
|
||||
|
||||
expect(data).toBeInstanceOf(Array);
|
||||
expect(data.length).toBe(0);
|
||||
});
|
||||
});
|
||||
86
api/api/v1/accounts/:id/followers.ts
Normal file
86
api/api/v1/accounts/:id/followers.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { Timeline } from "~/packages/database-interface/timeline";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 60,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/followers",
|
||||
auth: {
|
||||
required: false,
|
||||
oauthPermissions: ["read:accounts"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ViewAccountFollows,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
max_id: z.string().regex(idValidator).optional(),
|
||||
since_id: z.string().regex(idValidator).optional(),
|
||||
min_id: z.string().regex(idValidator).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
|
||||
}),
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { max_id, since_id, min_id, limit } =
|
||||
context.req.valid("query");
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
// TODO: Add follower/following privacy settings
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
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,
|
||||
context.req.url,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((object) => object.toApi())),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
101
api/api/v1/accounts/:id/following.test.ts
Normal file
101
api/api/v1/accounts/:id/following.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Account as ApiAccount } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./following";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// Follow user
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[1].id}/follow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
// /api/v1/accounts/:id/following
|
||||
describe(meta.route, () => {
|
||||
test("should return 200 with following", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiAccount[];
|
||||
|
||||
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 () => {
|
||||
// Unfollow user
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[1].id}/unfollow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const response2 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response2.status).toBe(200);
|
||||
|
||||
const data = (await response2.json()) as ApiAccount[];
|
||||
|
||||
expect(data).toBeInstanceOf(Array);
|
||||
expect(data.length).toBe(0);
|
||||
});
|
||||
});
|
||||
85
api/api/v1/accounts/:id/following.ts
Normal file
85
api/api/v1/accounts/:id/following.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { Timeline } from "~/packages/database-interface/timeline";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 60,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/following",
|
||||
auth: {
|
||||
required: false,
|
||||
oauthPermissions: ["read:accounts"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ViewAccountFollows,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
max_id: z.string().regex(idValidator).optional(),
|
||||
since_id: z.string().regex(idValidator).optional(),
|
||||
min_id: z.string().regex(idValidator).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
|
||||
}),
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { max_id, since_id, min_id } = context.req.valid("query");
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
// TODO: Add follower/following privacy settings
|
||||
|
||||
const { objects, link } = await Timeline.getUserTimeline(
|
||||
and(
|
||||
max_id ? lt(Users.id, max_id) : undefined,
|
||||
since_id ? gte(Users.id, since_id) : undefined,
|
||||
min_id ? gt(Users.id, min_id) : undefined,
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
|
||||
),
|
||||
context.req.valid("query").limit,
|
||||
context.req.url,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((object) => object.toApi())),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
99
api/api/v1/accounts/:id/index.test.ts
Normal file
99
api/api/v1/accounts/:id/index.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Account as ApiAccount } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestStatuses, getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
const timeline = (await getTestStatuses(40, users[0])).toReversed();
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const status of timeline) {
|
||||
await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/statuses/${status.id}/favourite`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// /api/v1/accounts/:id
|
||||
describe(meta.route, () => {
|
||||
test("should return 404 if ID is invalid", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", "invalid"),
|
||||
config.http.base_url,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("should return user", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiAccount;
|
||||
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: 40,
|
||||
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,
|
||||
icon: null,
|
||||
}),
|
||||
]),
|
||||
} satisfies ApiAccount);
|
||||
});
|
||||
});
|
||||
48
api/api/v1/accounts/:id/index.ts
Normal file
48
api/api/v1/accounts/:id/index.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id",
|
||||
auth: {
|
||||
required: false,
|
||||
oauthPermissions: [],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ViewAccounts],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
const foundUser = await User.fromId(id);
|
||||
|
||||
if (!foundUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
return context.json(foundUser.toApi(user?.id === foundUser.id));
|
||||
},
|
||||
),
|
||||
);
|
||||
102
api/api/v1/accounts/:id/mute.test.ts
Normal file
102
api/api/v1/accounts/:id/mute.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import type { Relationship as ApiRelationship } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./mute";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/accounts/:id/mute
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 404 if user not found", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(
|
||||
":id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should mute user", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const relationship = (await response.json()) as ApiRelationship;
|
||||
expect(relationship.muting).toBe(true);
|
||||
});
|
||||
|
||||
test("should return 200 if user already muted", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const relationship = (await response.json()) as ApiRelationship;
|
||||
expect(relationship.muting).toBe(true);
|
||||
});
|
||||
});
|
||||
79
api/api/v1/accounts/:id/mute.ts
Normal file
79
api/api/v1/accounts/:id/mute.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/mute",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:mutes"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnMutes,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
json: z.object({
|
||||
notifications: z.boolean().optional(),
|
||||
duration: z
|
||||
.number()
|
||||
.int()
|
||||
.min(60)
|
||||
.max(60 * 60 * 24 * 365 * 5)
|
||||
.optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
// TODO: Add duration support
|
||||
const { notifications } = context.req.valid("json");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
// TODO: Implement duration
|
||||
await foundRelationship.update({
|
||||
muting: true,
|
||||
mutingNotifications: notifications ?? true,
|
||||
});
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
70
api/api/v1/accounts/:id/note.ts
Normal file
70
api/api/v1/accounts/:id/note.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/note",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:accounts"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnAccount,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
json: z.object({
|
||||
comment: z.string().min(0).max(5000).trim().optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
const { comment } = context.req.valid("json");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
await foundRelationship.update({
|
||||
note: comment,
|
||||
});
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
65
api/api/v1/accounts/:id/pin.ts
Normal file
65
api/api/v1/accounts/:id/pin.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/pin",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:accounts"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnAccount,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
await foundRelationship.update({
|
||||
endorsed: true,
|
||||
});
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
54
api/api/v1/accounts/:id/refetch.ts
Normal file
54
api/api/v1/accounts/:id/refetch.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 4,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/refetch",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:accounts"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ViewAccounts],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const newUser = await otherUser.updateFromRemote();
|
||||
|
||||
return context.json(newUser.toApi(false));
|
||||
},
|
||||
),
|
||||
);
|
||||
72
api/api/v1/accounts/:id/remove_from_followers.ts
Normal file
72
api/api/v1/accounts/:id/remove_from_followers.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/remove_from_followers",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:follows"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnFollows,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user: self } = context.req.valid("header");
|
||||
|
||||
if (!self) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
||||
otherUser,
|
||||
self,
|
||||
);
|
||||
|
||||
if (oppositeRelationship.data.following) {
|
||||
await oppositeRelationship.update({
|
||||
following: false,
|
||||
});
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
self,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
190
api/api/v1/accounts/:id/statuses.test.ts
Normal file
190
api/api/v1/accounts/:id/statuses.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Status as ApiStatus } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestStatuses, getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./statuses";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
const timeline = (await getTestStatuses(40, users[1])).toReversed();
|
||||
const timeline2 = (await getTestStatuses(40, users[2])).toReversed();
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/statuses/${timeline2[0].id}/reblog`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
// /api/v1/accounts/:id/statuses
|
||||
describe(meta.route, () => {
|
||||
test("should return 200 with statuses", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiStatus[];
|
||||
|
||||
expect(data.length).toBe(20);
|
||||
// Should have reblogs
|
||||
expect(data[0].reblog).toBeDefined();
|
||||
});
|
||||
|
||||
test("should exclude reblogs", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route.replace(
|
||||
":id",
|
||||
users[1].id,
|
||||
)}?exclude_reblogs=true`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiStatus[];
|
||||
|
||||
expect(data.length).toBe(20);
|
||||
// Should not have reblogs
|
||||
expect(data[0].reblog).toBeNull();
|
||||
});
|
||||
|
||||
test("should exclude replies", async () => {
|
||||
// Create reply
|
||||
const replyResponse = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/statuses", config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
status: "Reply",
|
||||
in_reply_to_id: timeline[0].id,
|
||||
local_only: "true",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(replyResponse.status).toBe(200);
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route.replace(
|
||||
":id",
|
||||
users[1].id,
|
||||
)}?exclude_replies=true`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiStatus[];
|
||||
|
||||
expect(data.length).toBe(20);
|
||||
// Should not have replies
|
||||
expect(data[0].in_reply_to_id).toBeNull();
|
||||
});
|
||||
|
||||
test("should only include pins", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route.replace(":id", users[1].id)}?pinned=true`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiStatus[];
|
||||
|
||||
expect(data.length).toBe(0);
|
||||
|
||||
// Create pin
|
||||
const pinResponse = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/statuses/${timeline[3].id}/pin`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(pinResponse.status).toBe(200);
|
||||
|
||||
const response2 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route.replace(":id", users[1].id)}?pinned=true`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response2.status).toBe(200);
|
||||
|
||||
const data2 = (await response2.json()) as ApiStatus[];
|
||||
|
||||
expect(data2.length).toBe(1);
|
||||
});
|
||||
});
|
||||
120
api/api/v1/accounts/:id/statuses.ts
Normal file
120
api/api/v1/accounts/:id/statuses.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { Notes, RolePermissions } from "~/drizzle/schema";
|
||||
import { Timeline } from "~/packages/database-interface/timeline";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/statuses",
|
||||
auth: {
|
||||
required: false,
|
||||
oauthPermissions: ["read:statuses"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ViewNotes, RolePermissions.ViewAccounts],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
query: z.object({
|
||||
max_id: z.string().regex(idValidator).optional(),
|
||||
since_id: z.string().regex(idValidator).optional(),
|
||||
min_id: z.string().regex(idValidator).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
|
||||
only_media: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
exclude_replies: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
exclude_reblogs: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
pinned: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
tagged: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const {
|
||||
max_id,
|
||||
min_id,
|
||||
since_id,
|
||||
limit,
|
||||
exclude_reblogs,
|
||||
only_media,
|
||||
exclude_replies,
|
||||
pinned,
|
||||
} = context.req.valid("query");
|
||||
|
||||
const { objects, 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,
|
||||
eq(Notes.authorId, id),
|
||||
only_media
|
||||
? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})`
|
||||
: undefined,
|
||||
pinned
|
||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
|
||||
: undefined,
|
||||
exclude_reblogs ? isNull(Notes.reblogId) : undefined,
|
||||
exclude_replies ? isNull(Notes.replyId) : undefined,
|
||||
),
|
||||
limit,
|
||||
context.req.url,
|
||||
user?.id,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((note) => note.toApi(otherUser))),
|
||||
200,
|
||||
{
|
||||
link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
67
api/api/v1/accounts/:id/unblock.ts
Normal file
67
api/api/v1/accounts/:id/unblock.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/unblock",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:blocks"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnBlocks,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (foundRelationship.data.blocking) {
|
||||
await foundRelationship.update({
|
||||
blocking: false,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
65
api/api/v1/accounts/:id/unfollow.ts
Normal file
65
api/api/v1/accounts/:id/unfollow.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/unfollow",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:follows"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnFollows,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user: self } = context.req.valid("header");
|
||||
|
||||
if (!self) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
self,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (!(await self.unfollow(otherUser, foundRelationship))) {
|
||||
return context.json({ error: "Failed to unfollow user" }, 500);
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
109
api/api/v1/accounts/:id/unmute.test.ts
Normal file
109
api/api/v1/accounts/:id/unmute.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Relationship as ApiRelationship } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./unmute";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[0].id}/mute`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// /api/v1/accounts/:id/unmute
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 404 if user not found", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(
|
||||
":id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should unmute user", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const relationship = (await response.json()) as ApiRelationship;
|
||||
expect(relationship.muting).toBe(false);
|
||||
});
|
||||
|
||||
test("should return 200 if user already unmuted", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const relationship = (await response.json()) as ApiRelationship;
|
||||
expect(relationship.muting).toBe(false);
|
||||
});
|
||||
});
|
||||
68
api/api/v1/accounts/:id/unmute.ts
Normal file
68
api/api/v1/accounts/:id/unmute.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/unmute",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:mutes"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnMutes,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user: self } = context.req.valid("header");
|
||||
|
||||
if (!self) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const user = await User.fromId(id);
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
self,
|
||||
user,
|
||||
);
|
||||
|
||||
if (foundRelationship.data.muting) {
|
||||
await foundRelationship.update({
|
||||
muting: false,
|
||||
mutingNotifications: false,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
67
api/api/v1/accounts/:id/unpin.ts
Normal file
67
api/api/v1/accounts/:id/unpin.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/:id/unpin",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:accounts"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnAccount,
|
||||
RolePermissions.ViewAccounts,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user: self } = context.req.valid("header");
|
||||
|
||||
if (!self) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const otherUser = await User.fromId(id);
|
||||
|
||||
if (!otherUser) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
self,
|
||||
otherUser,
|
||||
);
|
||||
|
||||
if (foundRelationship.data.endorsed) {
|
||||
await foundRelationship.update({
|
||||
endorsed: false,
|
||||
});
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
97
api/api/v1/accounts/familiar_followers/index.ts
Normal file
97
api/api/v1/accounts/familiar_followers/index.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError, qsQuery } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/accounts/familiar_followers",
|
||||
ratelimits: {
|
||||
max: 5,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:follows"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnFollows],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
id: z.array(z.string().uuid()).min(1).max(10).or(z.string().uuid()),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
qsQuery(),
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user: self } = context.req.valid("header");
|
||||
const { id: ids } = context.req.valid("query");
|
||||
|
||||
if (!self) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const idFollowerRelationships =
|
||||
await db.query.Relationships.findMany({
|
||||
columns: {
|
||||
ownerId: true,
|
||||
},
|
||||
where: (relationship, { inArray, and, eq }) =>
|
||||
and(
|
||||
inArray(
|
||||
relationship.subjectId,
|
||||
Array.isArray(ids) ? ids : [ids],
|
||||
),
|
||||
eq(relationship.following, true),
|
||||
),
|
||||
});
|
||||
|
||||
if (idFollowerRelationships.length === 0) {
|
||||
return context.json([]);
|
||||
}
|
||||
|
||||
// Find users that you follow in idFollowerRelationships
|
||||
const relevantRelationships = await db.query.Relationships.findMany(
|
||||
{
|
||||
columns: {
|
||||
subjectId: true,
|
||||
},
|
||||
where: (relationship, { inArray, and, eq }) =>
|
||||
and(
|
||||
eq(relationship.ownerId, self.id),
|
||||
inArray(
|
||||
relationship.subjectId,
|
||||
idFollowerRelationships.map((f) => f.ownerId),
|
||||
),
|
||||
eq(relationship.following, true),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
if (relevantRelationships.length === 0) {
|
||||
return context.json([]);
|
||||
}
|
||||
|
||||
const finalUsers = await User.manyFromSql(
|
||||
inArray(
|
||||
Users.id,
|
||||
relevantRelationships.map((r) => r.subjectId),
|
||||
),
|
||||
);
|
||||
|
||||
return context.json(finalUsers.map((o) => o.toApi()));
|
||||
},
|
||||
),
|
||||
);
|
||||
44
api/api/v1/accounts/id/index.test.ts
Normal file
44
api/api/v1/accounts/id/index.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import type { Account as ApiAccount } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(5);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/accounts/id
|
||||
describe(meta.route, () => {
|
||||
test("should correctly get user from username", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?username=${users[0].data.username}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiAccount;
|
||||
|
||||
expect(data.id).toBe(users[0].id);
|
||||
});
|
||||
|
||||
test("should return 404 for non-existent user", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?username=${users[0].data.username}-nonexistent`,
|
||||
config.http.base_url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
50
api/api/v1/accounts/id/index.ts
Normal file
50
api/api/v1/accounts/id/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/id",
|
||||
auth: {
|
||||
required: false,
|
||||
oauthPermissions: [],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.Search],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
username: z.string().min(1).max(512),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { username } = context.req.valid("query");
|
||||
|
||||
const user = await User.fromSql(
|
||||
and(eq(Users.username, username), isNull(Users.instanceId)),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "User not found" }, 404);
|
||||
}
|
||||
|
||||
return context.json(user.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
246
api/api/v1/accounts/index.test.ts
Normal file
246
api/api/v1/accounts/index.test.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { randomString } from "@/math";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getSolvedChallenge, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
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(meta.route, () => {
|
||||
test("should create a new account", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
email: "bob@gamer.com",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("should refuse invalid emails", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
email: "bob",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("should require a password", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
email: "contatc@bob.com",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("should not allow a previously registered email", async () => {
|
||||
await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
email: "contact@george.com",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username2,
|
||||
email: "contact@george.com",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("should not allow a previously registered email (case insensitive)", async () => {
|
||||
await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
email: "contact@george.com",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username2,
|
||||
email: "CONTACT@george.CoM",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("should not allow invalid usernames (not a-z_0-9)", async () => {
|
||||
const response1 = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: "bob$",
|
||||
email: "contact@bob.com",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response1.status).toBe(422);
|
||||
|
||||
const response2 = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: "bob-markey",
|
||||
email: "contact@bob.com",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response2.status).toBe(422);
|
||||
|
||||
const response3 = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: "bob markey",
|
||||
email: "contact@bob.com",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response3.status).toBe(422);
|
||||
|
||||
const response4 = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: "BOB",
|
||||
email: "contact@bob.com",
|
||||
password: "password",
|
||||
agreement: "true",
|
||||
locale: "en",
|
||||
reason: "testing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response4.status).toBe(422);
|
||||
});
|
||||
});
|
||||
260
api/api/v1/accounts/index.ts
Normal file
260
api/api/v1/accounts/index.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api";
|
||||
import { response } from "@/response";
|
||||
import { tempmailDomains } from "@/tempmail";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import ISO6391 from "iso-639-1";
|
||||
import { z } from "zod";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/accounts",
|
||||
ratelimits: {
|
||||
max: 2,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
oauthPermissions: ["write:accounts"],
|
||||
},
|
||||
challenge: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
json: z.object({
|
||||
username: z.string(),
|
||||
email: z.string().toLowerCase(),
|
||||
password: z.string().optional(),
|
||||
agreement: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.or(z.boolean()),
|
||||
locale: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions, meta.challenge),
|
||||
jsonOrForm(),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
async (context) => {
|
||||
const form = context.req.valid("json");
|
||||
const { username, email, password, agreement, locale } =
|
||||
context.req.valid("json");
|
||||
|
||||
if (!config.signups.registration) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Registration is disabled",
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const errors: {
|
||||
details: Record<
|
||||
string,
|
||||
{
|
||||
error:
|
||||
| "ERR_BLANK"
|
||||
| "ERR_INVALID"
|
||||
| "ERR_TOO_LONG"
|
||||
| "ERR_TOO_SHORT"
|
||||
| "ERR_BLOCKED"
|
||||
| "ERR_TAKEN"
|
||||
| "ERR_RESERVED"
|
||||
| "ERR_ACCEPTED"
|
||||
| "ERR_INCLUSION";
|
||||
description: string;
|
||||
}[]
|
||||
>;
|
||||
} = {
|
||||
details: {
|
||||
password: [],
|
||||
username: [],
|
||||
email: [],
|
||||
agreement: [],
|
||||
locale: [],
|
||||
reason: [],
|
||||
},
|
||||
};
|
||||
|
||||
// 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.filters.username.some((filter) =>
|
||||
username?.match(filter),
|
||||
)
|
||||
) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_INVALID",
|
||||
description: "contains blocked words",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username is too long
|
||||
if ((username?.length ?? 0) > config.validation.max_username_size) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_TOO_LONG",
|
||||
description: `is too long (maximum is ${config.validation.max_username_size} characters)`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username is too short
|
||||
if ((username?.length ?? 0) < 3) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_TOO_SHORT",
|
||||
description: "is too short (minimum is 3 characters)",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username is reserved
|
||||
if (config.validation.username_blacklist.includes(username ?? "")) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_RESERVED",
|
||||
description: "is reserved",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username is taken
|
||||
if (
|
||||
await User.fromSql(
|
||||
and(eq(Users.username, username)),
|
||||
isNull(Users.instanceId),
|
||||
)
|
||||
) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_TAKEN",
|
||||
description: "is already taken",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email is valid
|
||||
if (
|
||||
!email?.match(
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
|
||||
)
|
||||
) {
|
||||
errors.details.email.push({
|
||||
error: "ERR_INVALID",
|
||||
description: "must be a valid email address",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email is blocked
|
||||
if (
|
||||
config.validation.email_blacklist.includes(email) ||
|
||||
(config.validation.blacklist_tempmail &&
|
||||
tempmailDomains.domains.includes(
|
||||
(email ?? "").split("@")[1],
|
||||
))
|
||||
) {
|
||||
errors.details.email.push({
|
||||
error: "ERR_BLOCKED",
|
||||
description: "is from a blocked email provider",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email is taken
|
||||
if (await User.fromSql(eq(Users.email, email))) {
|
||||
errors.details.email.push({
|
||||
error: "ERR_TAKEN",
|
||||
description: "is already taken",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if agreement is accepted
|
||||
if (!agreement) {
|
||||
errors.details.agreement.push({
|
||||
error: "ERR_ACCEPTED",
|
||||
description: "must be accepted",
|
||||
});
|
||||
}
|
||||
|
||||
if (!locale) {
|
||||
errors.details.locale.push({
|
||||
error: "ERR_BLANK",
|
||||
description: `can't be blank`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!ISO6391.validate(locale ?? "")) {
|
||||
errors.details.locale.push({
|
||||
error: "ERR_INVALID",
|
||||
description: "must be a valid ISO 639-1 code",
|
||||
});
|
||||
}
|
||||
|
||||
// If any errors are present, return them
|
||||
if (
|
||||
Object.values(errors.details).some((value) => value.length > 0)
|
||||
) {
|
||||
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
|
||||
|
||||
const errorsText = Object.entries(errors.details)
|
||||
.filter(([_, errors]) => errors.length > 0)
|
||||
.map(
|
||||
([name, errors]) =>
|
||||
`${name} ${errors
|
||||
.map((error) => error.description)
|
||||
.join(", ")}`,
|
||||
)
|
||||
.join(", ");
|
||||
return context.json(
|
||||
{
|
||||
error: `Validation failed: ${errorsText}`,
|
||||
details: Object.fromEntries(
|
||||
Object.entries(errors.details).filter(
|
||||
([_, errors]) => errors.length > 0,
|
||||
),
|
||||
),
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
await User.fromDataLocal({
|
||||
username: username ?? "",
|
||||
password: password ?? "",
|
||||
email: email ?? "",
|
||||
});
|
||||
|
||||
return response(null, 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
43
api/api/v1/accounts/lookup/index.test.ts
Normal file
43
api/api/v1/accounts/lookup/index.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import type { Account as ApiAccount } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/accounts/lookup
|
||||
describe(meta.route, () => {
|
||||
test("should return 200 with users", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?acct=${users[0].data.username}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiAccount[];
|
||||
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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
109
api/api/v1/accounts/lookup/index.ts
Normal file
109
api/api/v1/accounts/lookup/index.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
anyOf,
|
||||
charIn,
|
||||
createRegExp,
|
||||
digit,
|
||||
exactly,
|
||||
global,
|
||||
letter,
|
||||
maybe,
|
||||
oneOrMore,
|
||||
} from "magic-regexp";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/accounts/lookup",
|
||||
auth: {
|
||||
required: false,
|
||||
oauthPermissions: [],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.Search],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
acct: z.string().min(1).max(512),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { acct } = context.req.valid("query");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!acct) {
|
||||
return context.json({ error: "Invalid acct parameter" }, 400);
|
||||
}
|
||||
|
||||
// Check if acct is matching format username@domain.com or @username@domain.com
|
||||
const accountMatches = acct?.trim().match(
|
||||
createRegExp(
|
||||
maybe("@"),
|
||||
oneOrMore(
|
||||
anyOf(letter.lowercase, digit, charIn("-")),
|
||||
).groupedAs("username"),
|
||||
exactly("@"),
|
||||
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
|
||||
"domain",
|
||||
),
|
||||
|
||||
[global],
|
||||
),
|
||||
);
|
||||
|
||||
if (accountMatches) {
|
||||
// Remove leading @ if it exists
|
||||
if (accountMatches[0].startsWith("@")) {
|
||||
accountMatches[0] = accountMatches[0].slice(1);
|
||||
}
|
||||
|
||||
const [username, domain] = accountMatches[0].split("@");
|
||||
|
||||
const manager = await (user ?? User).getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(manager, username, domain);
|
||||
|
||||
const foundAccount = await User.resolve(uri);
|
||||
|
||||
if (foundAccount) {
|
||||
return context.json(foundAccount.toApi());
|
||||
}
|
||||
|
||||
return context.json({ error: "Account not found" }, 404);
|
||||
}
|
||||
|
||||
let username = acct;
|
||||
if (username.startsWith("@")) {
|
||||
username = username.slice(1);
|
||||
}
|
||||
|
||||
const account = await User.fromSql(eq(Users.username, username));
|
||||
|
||||
if (account) {
|
||||
return context.json(account.toApi());
|
||||
}
|
||||
|
||||
return context.json(
|
||||
{ error: `Account with username ${username} not found` },
|
||||
404,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
146
api/api/v1/accounts/relationships/index.test.ts
Normal file
146
api/api/v1/accounts/relationships/index.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, 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));
|
||||
|
||||
const res1 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[0].id}/follow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(res1.ok).toBe(true);
|
||||
|
||||
const res2 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[2].id}/follow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(res2.ok).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/accounts/relationships
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?id[]=${users[2].id}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return relationships", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?id[]=${users[2].id}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).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 () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?id[]=${users[1].id}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).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,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
60
api/api/v1/accounts/relationships/index.ts
Normal file
60
api/api/v1/accounts/relationships/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError, qsQuery } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/accounts/relationships",
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:follows"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnFollows],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
id: z.array(z.string().uuid()).min(1).max(10).or(z.string().uuid()),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
qsQuery(),
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user: self } = context.req.valid("header");
|
||||
const { id } = context.req.valid("query");
|
||||
|
||||
const ids = Array.isArray(id) ? id : [id];
|
||||
|
||||
if (!self) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const relationships = await Relationship.fromOwnerAndSubjects(
|
||||
self,
|
||||
ids,
|
||||
);
|
||||
|
||||
relationships.sort(
|
||||
(a, b) =>
|
||||
ids.indexOf(a.data.subjectId) -
|
||||
ids.indexOf(b.data.subjectId),
|
||||
);
|
||||
|
||||
return context.json(relationships.map((r) => r.toApi()));
|
||||
},
|
||||
),
|
||||
);
|
||||
45
api/api/v1/accounts/search/index.test.ts
Normal file
45
api/api/v1/accounts/search/index.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import type { Account as ApiAccount } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/accounts/search
|
||||
describe(meta.route, () => {
|
||||
test("should return 200 with users", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?q=${users[0].data.username}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as ApiAccount[];
|
||||
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),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
129
api/api/v1/accounts/search/index.ts
Normal file
129
api/api/v1/accounts/search/index.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq, ilike, not, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
anyOf,
|
||||
charIn,
|
||||
createRegExp,
|
||||
digit,
|
||||
exactly,
|
||||
global,
|
||||
letter,
|
||||
maybe,
|
||||
oneOrMore,
|
||||
} from "magic-regexp";
|
||||
import stringComparison from "string-comparison";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/accounts/search",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
oauthPermissions: ["read:accounts"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.Search, RolePermissions.ViewAccounts],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
q: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.regex(
|
||||
createRegExp(
|
||||
maybe("@"),
|
||||
oneOrMore(
|
||||
anyOf(letter.lowercase, digit, charIn("-")),
|
||||
).groupedAs("username"),
|
||||
maybe(
|
||||
exactly("@"),
|
||||
oneOrMore(
|
||||
anyOf(letter, digit, charIn("_-.:")),
|
||||
).groupedAs("domain"),
|
||||
),
|
||||
[global],
|
||||
),
|
||||
),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||
offset: z.coerce.number().int().optional(),
|
||||
resolve: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
following: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { q, limit, offset, resolve, following } =
|
||||
context.req.valid("query");
|
||||
const { user: self } = context.req.valid("header");
|
||||
|
||||
if (!self && following) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [username, host] = q.replace(/^@/, "").split("@");
|
||||
|
||||
const accounts: User[] = [];
|
||||
|
||||
if (resolve && username && host) {
|
||||
const manager = await (self ?? User).getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(manager, username, host);
|
||||
|
||||
const resolvedUser = await User.resolve(uri);
|
||||
|
||||
if (resolvedUser) {
|
||||
accounts.push(resolvedUser);
|
||||
}
|
||||
} else {
|
||||
accounts.push(
|
||||
...(await User.manyFromSql(
|
||||
or(
|
||||
ilike(Users.displayName, `%${q}%`),
|
||||
ilike(Users.username, `%${q}%`),
|
||||
following && self
|
||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)`
|
||||
: undefined,
|
||||
self ? not(eq(Users.id, self.id)) : undefined,
|
||||
),
|
||||
undefined,
|
||||
limit,
|
||||
offset,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
const indexOfCorrectSort = stringComparison.jaccardIndex
|
||||
.sortMatch(
|
||||
q,
|
||||
accounts.map((acct) => acct.getAcct()),
|
||||
)
|
||||
.map((sort) => sort.index);
|
||||
|
||||
const result = indexOfCorrectSort.map((index) => accounts[index]);
|
||||
|
||||
return context.json(result.map((acct) => acct.toApi()));
|
||||
},
|
||||
),
|
||||
);
|
||||
346
api/api/v1/accounts/update_credentials/index.ts
Normal file
346
api/api/v1/accounts/update_credentials/index.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api";
|
||||
import { sanitizedHtmlStrip } from "@/sanitization";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import ISO6391 from "iso-639-1";
|
||||
import { z } from "zod";
|
||||
import { contentToHtml } from "~/classes/functions/status";
|
||||
import { MediaManager } from "~/classes/media/media-manager";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { EmojiToUser, RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { Attachment } from "~/packages/database-interface/attachment";
|
||||
import { Emoji } from "~/packages/database-interface/emoji";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["PATCH"],
|
||||
route: "/api/v1/accounts/update_credentials",
|
||||
ratelimits: {
|
||||
max: 2,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:accounts"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnAccount],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
json: z.object({
|
||||
display_name: z
|
||||
.string()
|
||||
.min(3)
|
||||
.trim()
|
||||
.max(config.validation.max_displayname_size)
|
||||
.refine(
|
||||
(s) =>
|
||||
!config.filters.displayname.some((filter) =>
|
||||
s.match(filter),
|
||||
),
|
||||
"Display name contains blocked words",
|
||||
)
|
||||
.optional(),
|
||||
username: z
|
||||
.string()
|
||||
.min(3)
|
||||
.trim()
|
||||
.max(config.validation.max_username_size)
|
||||
.refine(
|
||||
(s) =>
|
||||
!config.filters.username.some((filter) => s.match(filter)),
|
||||
"Username contains blocked words",
|
||||
)
|
||||
.optional(),
|
||||
note: z
|
||||
.string()
|
||||
.min(0)
|
||||
.max(config.validation.max_bio_size)
|
||||
.trim()
|
||||
.refine(
|
||||
(s) => !config.filters.bio.some((filter) => s.match(filter)),
|
||||
"Bio contains blocked words",
|
||||
)
|
||||
.optional(),
|
||||
avatar: z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(v) => v.size <= config.validation.max_avatar_size,
|
||||
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
|
||||
)
|
||||
.optional(),
|
||||
header: z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(v) => v.size <= config.validation.max_header_size,
|
||||
`Header must be less than ${config.validation.max_header_size} bytes`,
|
||||
)
|
||||
.optional(),
|
||||
locked: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
bot: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
discoverable: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.optional(),
|
||||
source: z
|
||||
.object({
|
||||
privacy: z
|
||||
.enum(["public", "unlisted", "private", "direct"])
|
||||
.optional(),
|
||||
sensitive: z
|
||||
.string()
|
||||
.transform((v) =>
|
||||
["true", "1", "on"].includes(v.toLowerCase()),
|
||||
)
|
||||
.optional(),
|
||||
language: z
|
||||
.enum(ISO6391.getAllCodes() as [string, ...string[]])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
fields_attributes: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(config.validation.max_field_name_size),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(config.validation.max_field_value_size),
|
||||
}),
|
||||
)
|
||||
.max(config.validation.max_field_count)
|
||||
.optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
jsonOrForm(),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user } = context.req.valid("header");
|
||||
const {
|
||||
display_name,
|
||||
username,
|
||||
note,
|
||||
avatar,
|
||||
header,
|
||||
locked,
|
||||
bot,
|
||||
discoverable,
|
||||
source,
|
||||
fields_attributes,
|
||||
} = context.req.valid("json");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const self = user.data;
|
||||
|
||||
const sanitizedDisplayName = await sanitizedHtmlStrip(
|
||||
display_name ?? "",
|
||||
);
|
||||
|
||||
const mediaManager = new MediaManager(config);
|
||||
|
||||
if (display_name) {
|
||||
self.displayName = sanitizedDisplayName;
|
||||
}
|
||||
|
||||
if (note && self.source) {
|
||||
self.source.note = note;
|
||||
self.note = await contentToHtml({
|
||||
"text/markdown": {
|
||||
content: note,
|
||||
remote: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (source?.privacy) {
|
||||
self.source.privacy = source.privacy;
|
||||
}
|
||||
|
||||
if (source?.sensitive) {
|
||||
self.source.sensitive = source.sensitive;
|
||||
}
|
||||
|
||||
if (source?.language) {
|
||||
self.source.language = source.language;
|
||||
}
|
||||
|
||||
if (username) {
|
||||
// Check if username is already taken
|
||||
const existingUser = await User.fromSql(
|
||||
and(isNull(Users.instanceId), eq(Users.username, username)),
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
return context.json(
|
||||
{ error: "Username is already taken" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
self.username = username;
|
||||
}
|
||||
|
||||
if (avatar) {
|
||||
const { path } = await mediaManager.addFile(avatar);
|
||||
|
||||
self.avatar = Attachment.getUrl(path);
|
||||
}
|
||||
|
||||
if (header) {
|
||||
const { path } = await mediaManager.addFile(header);
|
||||
|
||||
self.header = Attachment.getUrl(path);
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
self.isLocked = locked;
|
||||
}
|
||||
|
||||
if (bot) {
|
||||
self.isBot = bot;
|
||||
}
|
||||
|
||||
if (discoverable) {
|
||||
self.isDiscoverable = discoverable;
|
||||
}
|
||||
|
||||
const fieldEmojis: Emoji[] = [];
|
||||
|
||||
if (fields_attributes) {
|
||||
self.fields = [];
|
||||
self.source.fields = [];
|
||||
for (const field of fields_attributes) {
|
||||
// Can be Markdown or plaintext, also has emojis
|
||||
const parsedName = await contentToHtml(
|
||||
{
|
||||
"text/markdown": {
|
||||
content: field.name,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
const parsedValue = await contentToHtml(
|
||||
{
|
||||
"text/markdown": {
|
||||
content: field.value,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
// Parse emojis
|
||||
const nameEmojis = await Emoji.parseFromText(parsedName);
|
||||
const valueEmojis = await Emoji.parseFromText(parsedValue);
|
||||
|
||||
fieldEmojis.push(...nameEmojis, ...valueEmojis);
|
||||
|
||||
// Replace fields
|
||||
self.fields.push({
|
||||
key: {
|
||||
"text/html": {
|
||||
content: parsedName,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
value: {
|
||||
"text/html": {
|
||||
content: parsedValue,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
self.source.fields.push({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse emojis
|
||||
const displaynameEmojis =
|
||||
await Emoji.parseFromText(sanitizedDisplayName);
|
||||
const noteEmojis = await Emoji.parseFromText(self.note);
|
||||
|
||||
self.emojis = [
|
||||
...displaynameEmojis,
|
||||
...noteEmojis,
|
||||
...fieldEmojis,
|
||||
].map((e) => e.data);
|
||||
|
||||
// Deduplicate emojis
|
||||
self.emojis = self.emojis.filter(
|
||||
(emoji, index, self) =>
|
||||
self.findIndex((e) => e.id === emoji.id) === index,
|
||||
);
|
||||
|
||||
// Connect emojis, if any
|
||||
// Do it before updating user, so that federation takes that into account
|
||||
for (const emoji of self.emojis) {
|
||||
await db
|
||||
.delete(EmojiToUser)
|
||||
.where(
|
||||
and(
|
||||
eq(EmojiToUser.emojiId, emoji.id),
|
||||
eq(EmojiToUser.userId, self.id),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.insert(EmojiToUser)
|
||||
.values({
|
||||
emojiId: emoji.id,
|
||||
userId: self.id,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
await user.update({
|
||||
displayName: self.displayName,
|
||||
username: self.username,
|
||||
note: self.note,
|
||||
avatar: self.avatar,
|
||||
header: self.header,
|
||||
fields: self.fields,
|
||||
isLocked: self.isLocked,
|
||||
isBot: self.isBot,
|
||||
isDiscoverable: self.isDiscoverable,
|
||||
source: self.source || undefined,
|
||||
});
|
||||
|
||||
const output = await User.fromId(self.id);
|
||||
if (!output) {
|
||||
return context.json({ error: "Couldn't edit user" }, 500);
|
||||
}
|
||||
|
||||
return context.json(output.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
32
api/api/v1/accounts/verify_credentials/index.ts
Normal file
32
api/api/v1/accounts/verify_credentials/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/accounts/verify_credentials",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:accounts"],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
(context) => {
|
||||
// TODO: Add checks for disabled/unverified accounts
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
return context.json(user.toApi(true));
|
||||
},
|
||||
),
|
||||
);
|
||||
80
api/api/v1/apps/index.ts
Normal file
80
api/api/v1/apps/index.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { apiRoute, applyConfig, handleZodError, jsonOrForm } from "@/api";
|
||||
import { randomString } from "@/math";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Applications, RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/apps",
|
||||
ratelimits: {
|
||||
max: 2,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnApps],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
json: z.object({
|
||||
client_name: z.string().trim().min(1).max(100),
|
||||
redirect_uris: z
|
||||
.string()
|
||||
.min(0)
|
||||
.max(2000)
|
||||
.url()
|
||||
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
|
||||
scopes: z.string().min(1).max(200),
|
||||
website: z
|
||||
.string()
|
||||
.min(0)
|
||||
.max(2000)
|
||||
.url()
|
||||
.optional()
|
||||
// Allow empty websites because Traewelling decides to give an empty
|
||||
// value instead of not providing anything at all
|
||||
.or(z.literal("").transform(() => undefined)),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
jsonOrForm(),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
async (context) => {
|
||||
const { client_name, redirect_uris, scopes, website } =
|
||||
context.req.valid("json");
|
||||
|
||||
const app = (
|
||||
await db
|
||||
.insert(Applications)
|
||||
.values({
|
||||
name: client_name || "",
|
||||
redirectUri: decodeURIComponent(redirect_uris) || "",
|
||||
scopes: scopes || "read",
|
||||
website: website || null,
|
||||
clientId: randomString(32, "base64url"),
|
||||
secret: randomString(64, "base64url"),
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
return context.json({
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
website: app.website,
|
||||
client_id: app.clientId,
|
||||
client_secret: app.secret,
|
||||
redirect_uri: app.redirectUri,
|
||||
vapid_link: app.vapidKey,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
50
api/api/v1/apps/verify_credentials/index.ts
Normal file
50
api/api/v1/apps/verify_credentials/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { getFromToken } from "~/classes/functions/application";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/apps/verify_credentials",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnApps],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user, token } = context.req.valid("header");
|
||||
|
||||
if (!token) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const application = await getFromToken(token);
|
||||
|
||||
if (!application) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
return context.json({
|
||||
name: application.name,
|
||||
website: application.website,
|
||||
vapid_key: application.vapidKey,
|
||||
redirect_uris: application.redirectUri,
|
||||
scopes: application.scopes,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
75
api/api/v1/blocks/index.ts
Normal file
75
api/api/v1/blocks/index.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { Timeline } from "~/packages/database-interface/timeline";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/blocks",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:blocks"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnBlocks],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
max_id: z.string().regex(idValidator).optional(),
|
||||
since_id: z.string().regex(idValidator).optional(),
|
||||
min_id: z.string().regex(idValidator).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { max_id, since_id, min_id, limit } =
|
||||
context.req.valid("query");
|
||||
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const { objects: blocks, link } = await Timeline.getUserTimeline(
|
||||
and(
|
||||
max_id ? lt(Users.id, max_id) : undefined,
|
||||
since_id ? gte(Users.id, since_id) : undefined,
|
||||
min_id ? gt(Users.id, min_id) : undefined,
|
||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
|
||||
),
|
||||
limit,
|
||||
context.req.url,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
blocks.map((u) => u.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
28
api/api/v1/challenges/index.test.ts
Normal file
28
api/api/v1/challenges/index.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
// /api/v1/challenges
|
||||
describe(meta.route, () => {
|
||||
test("should get a challenge", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
expect(body).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),
|
||||
});
|
||||
});
|
||||
});
|
||||
41
api/api/v1/challenges/index.ts
Normal file
41
api/api/v1/challenges/index.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { generateChallenge } from "@/challenges";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/challenges",
|
||||
ratelimits: {
|
||||
max: 10,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
permissions: {
|
||||
required: [],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
if (!config.validation.challenges.enabled) {
|
||||
return context.json(
|
||||
{ error: "Challenges are disabled in config" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await generateChallenge();
|
||||
|
||||
return context.json({
|
||||
id: result.id,
|
||||
...result.challenge,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
168
api/api/v1/custom_emojis/index.test.ts
Normal file
168
api/api/v1/custom_emojis/index.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Emojis } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, 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
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/emojis", config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
shortcode: "test1",
|
||||
element: "https://cdn.versia.social/logo.webp",
|
||||
global: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
await sendTestRequest(
|
||||
new Request(new URL("/api/v1/emojis", config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
shortcode: "test2",
|
||||
element: "https://cdn.versia.social/logo.webp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await sendTestRequest(
|
||||
new Request(new URL("/api/v1/emojis", config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
shortcode: "test3",
|
||||
element: "https://cdn.versia.social/logo.webp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
|
||||
await db
|
||||
.delete(Emojis)
|
||||
.where(inArray(Emojis.shortcode, ["test1", "test2", "test3"]));
|
||||
});
|
||||
|
||||
describe(meta.route, () => {
|
||||
test("should return all global emojis", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"application/json",
|
||||
);
|
||||
|
||||
const emojis = await response.json();
|
||||
|
||||
// Should contain test1 and test2, but not test2
|
||||
expect(emojis).toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: "test1",
|
||||
}),
|
||||
);
|
||||
expect(emojis).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: "test2",
|
||||
}),
|
||||
);
|
||||
expect(emojis).toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: "test3",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("should return all user emojis", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"application/json",
|
||||
);
|
||||
|
||||
const emojis = await response.json();
|
||||
|
||||
// Should contain test1 and test2, but not test3
|
||||
expect(emojis).toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: "test1",
|
||||
}),
|
||||
);
|
||||
expect(emojis).toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: "test2",
|
||||
}),
|
||||
);
|
||||
expect(emojis).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: "test3",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("should return all global emojis when signed out", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url)),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"application/json",
|
||||
);
|
||||
|
||||
const emojis = await response.json();
|
||||
|
||||
// Should contain test1, but not test2 or test3
|
||||
expect(emojis).toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: "test1",
|
||||
}),
|
||||
);
|
||||
expect(emojis).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: "test2",
|
||||
}),
|
||||
);
|
||||
expect(emojis).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: "test3",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
42
api/api/v1/custom_emojis/index.ts
Normal file
42
api/api/v1/custom_emojis/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { Emojis, RolePermissions } from "~/drizzle/schema";
|
||||
import { Emoji } from "~/packages/database-interface/emoji";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/custom_emojis",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ViewEmojis],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
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()));
|
||||
},
|
||||
),
|
||||
);
|
||||
217
api/api/v1/emojis/:id/index.test.ts
Normal file
217
api/api/v1/emojis/:id/index.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Emojis } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
let id = "";
|
||||
|
||||
// Make user 2 an admin
|
||||
beforeAll(async () => {
|
||||
await users[1].update({ isAdmin: true });
|
||||
|
||||
// Create an emoji
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/emojis", config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
shortcode: "test",
|
||||
element: "https://cdn.versia.social/logo.webp",
|
||||
global: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
id = emoji.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(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(meta.route.replace(":id", id), config.http.base_url),
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 404 if emoji does not exist", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(
|
||||
":id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should not work if the user is trying to update an emoji they don't own", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(meta.route.replace(":id", id), config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
shortcode: "test2",
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
test("should return the emoji", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(meta.route.replace(":id", id), config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
expect(emoji.shortcode).toBe("test");
|
||||
});
|
||||
|
||||
test("should update the emoji", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(meta.route.replace(":id", id), config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
shortcode: "test2",
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
expect(emoji.shortcode).toBe("test2");
|
||||
});
|
||||
|
||||
test("should update the emoji with another url, but keep the shortcode", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(meta.route.replace(":id", id), config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
element:
|
||||
"https://avatars.githubusercontent.com/u/30842467?v=4",
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
expect(emoji.shortcode).toBe("test2");
|
||||
});
|
||||
|
||||
test("should update the emoji to be non-global", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(meta.route.replace(":id", id), config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
global: false,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
// Check if the other user can see it
|
||||
const response2 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL("/api/v1/custom_emojis", config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response2.ok).toBe(true);
|
||||
const emojis = await response2.json();
|
||||
expect(emojis).not.toContainEqual(expect.objectContaining({ id: id }));
|
||||
});
|
||||
|
||||
test("should delete the emoji", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(meta.route.replace(":id", id), config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
208
api/api/v1/emojis/:id/index.ts
Normal file
208
api/api/v1/emojis/:id/index.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
emojiValidator,
|
||||
handleZodError,
|
||||
jsonOrForm,
|
||||
} from "@/api";
|
||||
import { mimeLookup } from "@/content_types";
|
||||
import { response } from "@/response";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { MediaManager } from "~/classes/media/media-manager";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Emojis, RolePermissions } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { Attachment } from "~/packages/database-interface/attachment";
|
||||
import { Emoji } from "~/packages/database-interface/emoji";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["DELETE", "GET", "PATCH"],
|
||||
route: "/api/v1/emojis/:id",
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnEmojis, RolePermissions.ViewEmojis],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
json: z
|
||||
.object({
|
||||
shortcode: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
emojiValidator,
|
||||
"Shortcode must only contain letters (any case), numbers, dashes or underscores.",
|
||||
),
|
||||
element: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.url()
|
||||
.or(z.instanceof(File)),
|
||||
category: z.string().max(64).optional(),
|
||||
alt: z.string().max(1000).optional(),
|
||||
global: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.or(z.boolean())
|
||||
.optional(),
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
jsonOrForm(),
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const emoji = await Emoji.fromId(id);
|
||||
|
||||
if (!emoji) {
|
||||
return context.json({ error: "Emoji not found" }, 404);
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
if (
|
||||
!user.hasPermission(RolePermissions.ManageEmojis) &&
|
||||
emoji.data.ownerId !== user.data.id
|
||||
) {
|
||||
return context.json(
|
||||
{
|
||||
error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const mediaManager = new MediaManager(config);
|
||||
|
||||
switch (context.req.method) {
|
||||
case "DELETE": {
|
||||
await mediaManager.deleteFileByUrl(emoji.data.url);
|
||||
|
||||
await db.delete(Emojis).where(eq(Emojis.id, id));
|
||||
|
||||
return response(null, 204);
|
||||
}
|
||||
|
||||
case "PATCH": {
|
||||
const form = context.req.valid("json");
|
||||
|
||||
if (!form) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Invalid form data (must supply at least one of: shortcode, element, alt, category)",
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
form.shortcode ||
|
||||
form.element ||
|
||||
form.alt ||
|
||||
form.category
|
||||
) &&
|
||||
form.global === undefined
|
||||
) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Invalid form data (must supply at least one of: shortcode, element, alt, category)",
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!user.hasPermission(RolePermissions.ManageEmojis) &&
|
||||
form.global
|
||||
) {
|
||||
return context.json(
|
||||
{
|
||||
error: `Only users with the '${RolePermissions.ManageEmojis}' permission can make an emoji global or not`,
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const modified = structuredClone(emoji.data);
|
||||
|
||||
if (form.element) {
|
||||
// Check of emoji is an image
|
||||
let contentType =
|
||||
form.element instanceof File
|
||||
? form.element.type
|
||||
: await mimeLookup(form.element);
|
||||
|
||||
if (!contentType.startsWith("image/")) {
|
||||
return context.json(
|
||||
{
|
||||
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
let url = "";
|
||||
|
||||
if (form.element instanceof File) {
|
||||
const uploaded = await mediaManager.addFile(
|
||||
form.element,
|
||||
);
|
||||
|
||||
url = uploaded.path;
|
||||
contentType = uploaded.uploadedFile.type;
|
||||
} else {
|
||||
url = form.element;
|
||||
}
|
||||
|
||||
modified.url = Attachment.getUrl(url);
|
||||
modified.contentType = contentType;
|
||||
}
|
||||
|
||||
modified.shortcode = form.shortcode ?? modified.shortcode;
|
||||
modified.alt = form.alt ?? modified.alt;
|
||||
modified.category = form.category ?? modified.category;
|
||||
modified.ownerId = form.global ? null : user.data.id;
|
||||
|
||||
await emoji.update(modified);
|
||||
|
||||
return context.json(emoji.toApi());
|
||||
}
|
||||
|
||||
case "GET": {
|
||||
return context.json(emoji.toApi());
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
202
api/api/v1/emojis/index.test.ts
Normal file
202
api/api/v1/emojis/index.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import sharp from "sharp";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Emojis } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, 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(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
shortcode: "test",
|
||||
element: "https://cdn.versia.social/logo.webp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
describe("Admin tests", () => {
|
||||
test("should upload a file and create an emoji", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("shortcode", "test1");
|
||||
formData.append("element", await createImage("test.png"));
|
||||
formData.append("global", "true");
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
expect(emoji.shortcode).toBe("test1");
|
||||
expect(emoji.url).toContain("/media/proxy");
|
||||
});
|
||||
|
||||
test("should try to upload a non-image", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("shortcode", "test2");
|
||||
formData.append("element", new File(["test"], "test.txt"));
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("should upload an emoji by url", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
shortcode: "test3",
|
||||
element: "https://cdn.versia.social/logo.webp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
expect(emoji.shortcode).toBe("test3");
|
||||
expect(emoji.url).toContain("/media/proxy/");
|
||||
});
|
||||
|
||||
test("should fail when uploading an already existing emoji", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("shortcode", "test1");
|
||||
formData.append("element", await createImage("test-image.png"));
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User tests", () => {
|
||||
test("should upload a file and create an emoji", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("shortcode", "test4");
|
||||
formData.append("element", await createImage("test-image.png"));
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
expect(emoji.shortcode).toBe("test4");
|
||||
expect(emoji.url).toContain("/media/proxy/");
|
||||
});
|
||||
|
||||
test("should fail when uploading an already existing global emoji", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("shortcode", "test1");
|
||||
formData.append("element", await createImage("test-image.png"));
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("should create an emoji as another user with the same shortcode", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("shortcode", "test4");
|
||||
formData.append("element", await createImage("test-image.png"));
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[2].accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
expect(emoji.shortcode).toBe("test4");
|
||||
expect(emoji.url).toContain("/media/proxy/");
|
||||
});
|
||||
});
|
||||
});
|
||||
146
api/api/v1/emojis/index.ts
Normal file
146
api/api/v1/emojis/index.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
emojiValidator,
|
||||
handleZodError,
|
||||
jsonOrForm,
|
||||
} from "@/api";
|
||||
import { mimeLookup } from "@/content_types";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { MediaManager } from "~/classes/media/media-manager";
|
||||
import { Emojis, RolePermissions } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { Attachment } from "~/packages/database-interface/attachment";
|
||||
import { Emoji } from "~/packages/database-interface/emoji";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/emojis",
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnEmojis, RolePermissions.ViewEmojis],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
json: z.object({
|
||||
shortcode: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
emojiValidator,
|
||||
"Shortcode must only contain letters (any case), numbers, dashes or underscores.",
|
||||
),
|
||||
element: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.url()
|
||||
.or(z.instanceof(File)),
|
||||
category: z.string().max(64).optional(),
|
||||
alt: z.string().max(1000).optional(),
|
||||
global: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.or(z.boolean())
|
||||
.optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
jsonOrForm(),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { shortcode, element, alt, global, category } =
|
||||
context.req.valid("json");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
if (!user.hasPermission(RolePermissions.ManageEmojis) && global) {
|
||||
return context.json(
|
||||
{
|
||||
error: `Only users with the '${RolePermissions.ManageEmojis}' permission can upload global emojis`,
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if emoji already exists
|
||||
const existing = await Emoji.fromSql(
|
||||
and(
|
||||
eq(Emojis.shortcode, shortcode),
|
||||
isNull(Emojis.instanceId),
|
||||
or(eq(Emojis.ownerId, user.id), isNull(Emojis.ownerId)),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return context.json(
|
||||
{
|
||||
error: `An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
let url = "";
|
||||
|
||||
// Check of emoji is an image
|
||||
let contentType =
|
||||
element instanceof File
|
||||
? element.type
|
||||
: await mimeLookup(element);
|
||||
|
||||
if (!contentType.startsWith("image/")) {
|
||||
return context.json(
|
||||
{
|
||||
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (element instanceof File) {
|
||||
const mediaManager = new MediaManager(config);
|
||||
|
||||
const uploaded = await mediaManager.addFile(element);
|
||||
|
||||
url = uploaded.path;
|
||||
contentType = uploaded.uploadedFile.type;
|
||||
} else {
|
||||
url = element;
|
||||
}
|
||||
|
||||
const emoji = await Emoji.insert({
|
||||
shortcode,
|
||||
url: Attachment.getUrl(url),
|
||||
visibleInPicker: true,
|
||||
ownerId: global ? null : user.id,
|
||||
category,
|
||||
contentType,
|
||||
alt,
|
||||
});
|
||||
|
||||
return context.json(emoji.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
78
api/api/v1/favourites/index.ts
Normal file
78
api/api/v1/favourites/index.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { Notes, RolePermissions } from "~/drizzle/schema";
|
||||
import { Timeline } from "~/packages/database-interface/timeline";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/favourites",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnLikes],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
max_id: z.string().regex(idValidator).optional(),
|
||||
since_id: z.string().regex(idValidator).optional(),
|
||||
min_id: z.string().regex(idValidator).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { max_id, since_id, min_id, limit } =
|
||||
context.req.valid("query");
|
||||
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
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,
|
||||
context.req.url,
|
||||
user?.id,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(
|
||||
favourites.map(async (note) => note.toApi(user)),
|
||||
),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
75
api/api/v1/follow_requests/:account_id/authorize.ts
Normal file
75
api/api/v1/follow_requests/:account_id/authorize.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { sendFollowAccept } from "~/classes/functions/user";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/follow_requests/:account_id/authorize",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnFollows],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
account_id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const { account_id } = context.req.valid("param");
|
||||
|
||||
const account = await User.fromId(account_id);
|
||||
|
||||
if (!account) {
|
||||
return context.json({ error: "Account not found" }, 404);
|
||||
}
|
||||
|
||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
||||
account,
|
||||
user,
|
||||
);
|
||||
|
||||
await oppositeRelationship.update({
|
||||
requested: false,
|
||||
following: true,
|
||||
});
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
account,
|
||||
);
|
||||
|
||||
// Check if accepting remote follow
|
||||
if (account.isRemote()) {
|
||||
// Federate follow accept
|
||||
await sendFollowAccept(account, user);
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
75
api/api/v1/follow_requests/:account_id/reject.ts
Normal file
75
api/api/v1/follow_requests/:account_id/reject.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { sendFollowReject } from "~/classes/functions/user";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Relationship } from "~/packages/database-interface/relationship";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/follow_requests/:account_id/reject",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnFollows],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
account_id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const { account_id } = context.req.valid("param");
|
||||
|
||||
const account = await User.fromId(account_id);
|
||||
|
||||
if (!account) {
|
||||
return context.json({ error: "Account not found" }, 404);
|
||||
}
|
||||
|
||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
||||
account,
|
||||
user,
|
||||
);
|
||||
|
||||
await oppositeRelationship.update({
|
||||
requested: false,
|
||||
following: false,
|
||||
});
|
||||
|
||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||
user,
|
||||
account,
|
||||
);
|
||||
|
||||
// Check if rejecting remote follow
|
||||
if (account.isRemote()) {
|
||||
// Federate follow reject
|
||||
await sendFollowReject(account, user);
|
||||
}
|
||||
|
||||
return context.json(foundRelationship.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
75
api/api/v1/follow_requests/index.ts
Normal file
75
api/api/v1/follow_requests/index.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { Timeline } from "~/packages/database-interface/timeline";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/follow_requests",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnFollows],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
max_id: z.string().regex(idValidator).optional(),
|
||||
since_id: z.string().regex(idValidator).optional(),
|
||||
min_id: z.string().regex(idValidator).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { max_id, since_id, min_id, limit } =
|
||||
context.req.valid("query");
|
||||
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
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,
|
||||
context.req.url,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
followRequests.map((u) => u.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
20
api/api/v1/frontend/config/index.ts
Normal file
20
api/api/v1/frontend/config/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { apiRoute, applyConfig } from "@/api";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
ratelimits: {
|
||||
duration: 60,
|
||||
max: 120,
|
||||
},
|
||||
route: "/api/v1/frontend/config",
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(meta.allowedMethods, meta.route, (context) => {
|
||||
return context.json(config.frontend.settings);
|
||||
}),
|
||||
);
|
||||
22
api/api/v1/instance/extended_description.test.ts
Normal file
22
api/api/v1/instance/extended_description.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./extended_description";
|
||||
|
||||
// /api/v1/instance/extended_description
|
||||
describe(meta.route, () => {
|
||||
test("should return extended description", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url)),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json).toEqual({
|
||||
updated_at: new Date(1970, 0, 0).toISOString(),
|
||||
content:
|
||||
'<p>This is a <a href="https://versia.pub">Versia</a> server with the default extended description.</p>\n',
|
||||
});
|
||||
});
|
||||
});
|
||||
34
api/api/v1/instance/extended_description.ts
Normal file
34
api/api/v1/instance/extended_description.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { renderMarkdownInPath } from "@/markdown";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/instance/extended_description",
|
||||
ratelimits: {
|
||||
max: 300,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { content, lastModified } = await renderMarkdownInPath(
|
||||
config.instance.extended_description_path ?? "",
|
||||
"This is a [Versia](https://versia.pub) server with the default extended description.",
|
||||
);
|
||||
|
||||
return context.json({
|
||||
updated_at: lastModified.toISOString(),
|
||||
content,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
106
api/api/v1/instance/index.ts
Normal file
106
api/api/v1/instance/index.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { proxyUrl } from "@/response";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
import manifest from "~/package.json";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { Instance } from "~/packages/database-interface/instance";
|
||||
import { Note } from "~/packages/database-interface/note";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/instance",
|
||||
ratelimits: {
|
||||
max: 300,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
// Get software version from package.json
|
||||
const version = manifest.version;
|
||||
|
||||
const statusCount = await Note.getCount();
|
||||
|
||||
const userCount = await User.getCount();
|
||||
|
||||
const contactAccount = await User.fromSql(
|
||||
and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
|
||||
);
|
||||
|
||||
const knownDomainsCount = await Instance.getCount();
|
||||
|
||||
// TODO: fill in more values
|
||||
return context.json({
|
||||
approval_required: false,
|
||||
configuration: {
|
||||
polls: {
|
||||
max_characters_per_option:
|
||||
config.validation.max_poll_option_size,
|
||||
max_expiration: config.validation.max_poll_duration,
|
||||
max_options: config.validation.max_poll_options,
|
||||
min_expiration: config.validation.min_poll_duration,
|
||||
},
|
||||
statuses: {
|
||||
characters_reserved_per_url: 0,
|
||||
max_characters: config.validation.max_note_size,
|
||||
max_media_attachments:
|
||||
config.validation.max_media_attachments,
|
||||
},
|
||||
},
|
||||
description: config.instance.description,
|
||||
email: "",
|
||||
invites_enabled: false,
|
||||
registrations: config.signups.registration,
|
||||
languages: ["en"],
|
||||
rules: config.signups.rules.map((r, index) => ({
|
||||
id: String(index),
|
||||
text: r,
|
||||
})),
|
||||
stats: {
|
||||
domain_count: knownDomainsCount,
|
||||
status_count: statusCount,
|
||||
user_count: userCount,
|
||||
},
|
||||
thumbnail: proxyUrl(config.instance.logo),
|
||||
banner: proxyUrl(config.instance.banner),
|
||||
title: config.instance.name,
|
||||
uri: config.http.base_url,
|
||||
urls: {
|
||||
streaming_api: "",
|
||||
},
|
||||
version: "4.3.0-alpha.3+glitch",
|
||||
versia_version: version,
|
||||
sso: {
|
||||
forced: false,
|
||||
providers: config.oidc.providers.map((p) => ({
|
||||
name: p.name,
|
||||
icon: proxyUrl(p.icon) || undefined,
|
||||
id: p.id,
|
||||
})),
|
||||
},
|
||||
contact_account: contactAccount?.toApi() || undefined,
|
||||
} satisfies Record<string, unknown> & {
|
||||
banner: string | null;
|
||||
versia_version: string;
|
||||
sso: {
|
||||
forced: boolean;
|
||||
providers: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
}[];
|
||||
};
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
23
api/api/v1/instance/privacy_policy.test.ts
Normal file
23
api/api/v1/instance/privacy_policy.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./privacy_policy";
|
||||
|
||||
// /api/v1/instance/privacy_policy
|
||||
describe(meta.route, () => {
|
||||
test("should return privacy policy", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url)),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json).toEqual({
|
||||
updated_at: new Date(1970, 0, 0).toISOString(),
|
||||
// This instance has not provided any privacy policy.
|
||||
content:
|
||||
"<p>This instance has not provided any privacy policy.</p>\n",
|
||||
});
|
||||
});
|
||||
});
|
||||
34
api/api/v1/instance/privacy_policy.ts
Normal file
34
api/api/v1/instance/privacy_policy.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { renderMarkdownInPath } from "@/markdown";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/instance/privacy_policy",
|
||||
ratelimits: {
|
||||
max: 300,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { content, lastModified } = await renderMarkdownInPath(
|
||||
config.instance.privacy_policy_path ?? "",
|
||||
"This instance has not provided any privacy policy.",
|
||||
);
|
||||
|
||||
return context.json({
|
||||
updated_at: lastModified.toISOString(),
|
||||
content,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
24
api/api/v1/instance/rules.test.ts
Normal file
24
api/api/v1/instance/rules.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./rules";
|
||||
|
||||
// /api/v1/instance/rules
|
||||
describe(meta.route, () => {
|
||||
test("should return rules", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url)),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json).toEqual(
|
||||
config.signups.rules.map((rule, index) => ({
|
||||
id: String(index),
|
||||
text: rule,
|
||||
hint: "",
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
31
api/api/v1/instance/rules.ts
Normal file
31
api/api/v1/instance/rules.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/instance/rules",
|
||||
ratelimits: {
|
||||
max: 300,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
return context.json(
|
||||
config.signups.rules.map((rule, index) => ({
|
||||
id: String(index),
|
||||
text: rule,
|
||||
hint: "",
|
||||
})),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
23
api/api/v1/instance/tos.test.ts
Normal file
23
api/api/v1/instance/tos.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./tos";
|
||||
|
||||
// /api/v1/instance/tos
|
||||
describe(meta.route, () => {
|
||||
test("should return terms of service", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url)),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json).toEqual({
|
||||
updated_at: new Date(1970, 0, 0).toISOString(),
|
||||
// This instance has not provided any terms of service.
|
||||
content:
|
||||
"<p>This instance has not provided any terms of service.</p>\n",
|
||||
});
|
||||
});
|
||||
});
|
||||
34
api/api/v1/instance/tos.ts
Normal file
34
api/api/v1/instance/tos.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { renderMarkdownInPath } from "@/markdown";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/instance/tos",
|
||||
ratelimits: {
|
||||
max: 300,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { content, lastModified } = await renderMarkdownInPath(
|
||||
config.instance.tos_path ?? "",
|
||||
"This instance has not provided any terms of service.",
|
||||
);
|
||||
|
||||
return context.json({
|
||||
updated_at: lastModified.toISOString(),
|
||||
content,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
103
api/api/v1/markers/index.test.ts
Normal file
103
api/api/v1/markers/index.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestStatuses, getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(1);
|
||||
const timeline = await getTestStatuses(10, users[0]);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/markers
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "GET",
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return empty markers", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
`${new URL(
|
||||
meta.route,
|
||||
config.http.base_url,
|
||||
)}?${new URLSearchParams([
|
||||
["timeline[]", "home"],
|
||||
["timeline[]", "notifications"],
|
||||
])}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({});
|
||||
});
|
||||
|
||||
test("should create markers", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?${new URLSearchParams({
|
||||
"home[last_read_id]": timeline[0].id,
|
||||
})}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
home: {
|
||||
last_read_id: timeline[0].id,
|
||||
updated_at: expect.any(String),
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return markers", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
`${new URL(
|
||||
meta.route,
|
||||
config.http.base_url,
|
||||
)}?${new URLSearchParams([
|
||||
["timeline[]", "home"],
|
||||
["timeline[]", "notifications"],
|
||||
])}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
home: {
|
||||
last_read_id: timeline[0].id,
|
||||
updated_at: expect.any(String),
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
219
api/api/v1/markers/index.ts
Normal file
219
api/api/v1/markers/index.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import type { Marker as ApiMarker } from "@versia/client/types";
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Markers, RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET", "POST"],
|
||||
route: "/api/v1/markers",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:blocks"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnAccount],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
"timeline[]": z
|
||||
.array(z.enum(["home", "notifications"]))
|
||||
.max(2)
|
||||
.or(z.enum(["home", "notifications"]))
|
||||
.optional(),
|
||||
"home[last_read_id]": z.string().regex(idValidator).optional(),
|
||||
"notifications[last_read_id]": z.string().regex(idValidator).optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { "timeline[]": timelines } = context.req.valid("query");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
const timeline = Array.isArray(timelines) ? timelines : [];
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
switch (context.req.method) {
|
||||
case "GET": {
|
||||
if (!timeline) {
|
||||
return context.json({});
|
||||
}
|
||||
|
||||
const markers: ApiMarker = {
|
||||
home: undefined,
|
||||
notifications: undefined,
|
||||
};
|
||||
|
||||
if (timeline.includes("home")) {
|
||||
const found = await db.query.Markers.findFirst({
|
||||
where: (marker, { and, eq }) =>
|
||||
and(
|
||||
eq(marker.userId, user.id),
|
||||
eq(marker.timeline, "home"),
|
||||
),
|
||||
});
|
||||
|
||||
const totalCount = await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Markers)
|
||||
.where(
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "home"),
|
||||
),
|
||||
);
|
||||
|
||||
if (found?.noteId) {
|
||||
markers.home = {
|
||||
last_read_id: found.noteId,
|
||||
version: totalCount[0].count,
|
||||
updated_at: new Date(
|
||||
found.createdAt,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (timeline.includes("notifications")) {
|
||||
const found = await db.query.Markers.findFirst({
|
||||
where: (marker, { and, eq }) =>
|
||||
and(
|
||||
eq(marker.userId, user.id),
|
||||
eq(marker.timeline, "notifications"),
|
||||
),
|
||||
});
|
||||
|
||||
const totalCount = await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Markers)
|
||||
.where(
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "notifications"),
|
||||
),
|
||||
);
|
||||
|
||||
if (found?.notificationId) {
|
||||
markers.notifications = {
|
||||
last_read_id: found.notificationId,
|
||||
version: totalCount[0].count,
|
||||
updated_at: new Date(
|
||||
found.createdAt,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return context.json(markers);
|
||||
}
|
||||
|
||||
case "POST": {
|
||||
const {
|
||||
"home[last_read_id]": homeId,
|
||||
"notifications[last_read_id]": notificationsId,
|
||||
} = context.req.valid("query");
|
||||
|
||||
const markers: ApiMarker = {
|
||||
home: undefined,
|
||||
notifications: undefined,
|
||||
};
|
||||
|
||||
if (homeId) {
|
||||
const insertedMarker = (
|
||||
await db
|
||||
.insert(Markers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
timeline: "home",
|
||||
noteId: homeId,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
const totalCount = await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Markers)
|
||||
.where(
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "home"),
|
||||
),
|
||||
);
|
||||
|
||||
markers.home = {
|
||||
last_read_id: homeId,
|
||||
version: totalCount[0].count,
|
||||
updated_at: new Date(
|
||||
insertedMarker.createdAt,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (notificationsId) {
|
||||
const insertedMarker = (
|
||||
await db
|
||||
.insert(Markers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
timeline: "notifications",
|
||||
notificationId: notificationsId,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
const totalCount = await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Markers)
|
||||
.where(
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "notifications"),
|
||||
),
|
||||
);
|
||||
|
||||
markers.notifications = {
|
||||
last_read_id: notificationsId,
|
||||
version: totalCount[0].count,
|
||||
updated_at: new Date(
|
||||
insertedMarker.createdAt,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return context.json(markers);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
111
api/api/v1/media/:id/index.ts
Normal file
111
api/api/v1/media/:id/index.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { response } from "@/response";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { MediaManager } from "~/classes/media/media-manager";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { Attachment } from "~/packages/database-interface/attachment";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET", "PUT"],
|
||||
ratelimits: {
|
||||
max: 10,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/media/:id",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:media"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnMedia],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
form: z.object({
|
||||
thumbnail: z.instanceof(File).optional(),
|
||||
description: z
|
||||
.string()
|
||||
.max(config.validation.max_media_description_size)
|
||||
.optional(),
|
||||
focus: z.string().optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("form", schemas.form, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
if (!id.match(idValidator)) {
|
||||
return context.json(
|
||||
{ error: "Invalid ID, must be of type UUIDv7" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
const attachment = await Attachment.fromId(id);
|
||||
|
||||
if (!attachment) {
|
||||
return context.json({ error: "Media not found" }, 404);
|
||||
}
|
||||
|
||||
switch (context.req.method) {
|
||||
case "GET": {
|
||||
if (attachment.data.url) {
|
||||
return context.json(attachment.toApi());
|
||||
}
|
||||
return response(null, 206);
|
||||
}
|
||||
case "PUT": {
|
||||
const { description, thumbnail } =
|
||||
context.req.valid("form");
|
||||
|
||||
let thumbnailUrl = attachment.data.thumbnailUrl;
|
||||
|
||||
const mediaManager = new MediaManager(config);
|
||||
|
||||
if (thumbnail) {
|
||||
const { path } = await mediaManager.addFile(thumbnail);
|
||||
thumbnailUrl = Attachment.getUrl(path);
|
||||
}
|
||||
|
||||
const descriptionText =
|
||||
description || attachment.data.description;
|
||||
|
||||
if (
|
||||
descriptionText !== attachment.data.description ||
|
||||
thumbnailUrl !== attachment.data.thumbnailUrl
|
||||
) {
|
||||
await attachment.update({
|
||||
description: descriptionText,
|
||||
thumbnailUrl,
|
||||
});
|
||||
|
||||
return context.json(attachment.toApi());
|
||||
}
|
||||
|
||||
return context.json(attachment.toApi());
|
||||
}
|
||||
}
|
||||
|
||||
return context.json({ error: "Method not allowed" }, 405);
|
||||
},
|
||||
),
|
||||
);
|
||||
102
api/api/v1/media/index.ts
Normal file
102
api/api/v1/media/index.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { MediaManager } from "~/classes/media/media-manager";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { Attachment } from "~/packages/database-interface/attachment";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 10,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/media",
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:media"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnMedia],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
form: z.object({
|
||||
file: z.instanceof(File),
|
||||
thumbnail: z.instanceof(File).optional(),
|
||||
description: z
|
||||
.string()
|
||||
.max(config.validation.max_media_description_size)
|
||||
.optional(),
|
||||
focus: z.string().optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("form", schemas.form, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { file, thumbnail, description } = context.req.valid("form");
|
||||
|
||||
if (file.size > config.validation.max_media_size) {
|
||||
return context.json(
|
||||
{
|
||||
error: `File too large, max size is ${config.validation.max_media_size} bytes`,
|
||||
},
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
config.validation.enforce_mime_types &&
|
||||
!config.validation.allowed_mime_types.includes(file.type)
|
||||
) {
|
||||
return context.json({ error: "Invalid file type" }, 415);
|
||||
}
|
||||
|
||||
const sha256 = new Bun.SHA256();
|
||||
|
||||
const isImage = file.type.startsWith("image/");
|
||||
|
||||
const metadata = isImage
|
||||
? await sharp(await file.arrayBuffer()).metadata()
|
||||
: null;
|
||||
|
||||
const mediaManager = new MediaManager(config);
|
||||
|
||||
const { path, blurhash } = await mediaManager.addFile(file);
|
||||
|
||||
const url = Attachment.getUrl(path);
|
||||
|
||||
let thumbnailUrl = "";
|
||||
|
||||
if (thumbnail) {
|
||||
const { path } = await mediaManager.addFile(thumbnail);
|
||||
|
||||
thumbnailUrl = Attachment.getUrl(path);
|
||||
}
|
||||
|
||||
const newAttachment = await Attachment.insert({
|
||||
url,
|
||||
thumbnailUrl,
|
||||
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
|
||||
mimeType: file.type,
|
||||
description: description ?? "",
|
||||
size: file.size,
|
||||
blurhash: blurhash ?? undefined,
|
||||
width: metadata?.width ?? undefined,
|
||||
height: metadata?.height ?? undefined,
|
||||
});
|
||||
|
||||
// TODO: Add job to process videos and other media
|
||||
|
||||
return context.json(newAttachment.toApi());
|
||||
},
|
||||
),
|
||||
);
|
||||
100
api/api/v1/mutes/index.test.ts
Normal file
100
api/api/v1/mutes/index.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(3);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[1].id}/mute`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
// /api/v1/mutes
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", users[1].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return mutes", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: users[1].id,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("should return mutes after unmute", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[1].id}/unmute`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const response2 = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(response2.status).toBe(200);
|
||||
const body = await response2.json();
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
});
|
||||
74
api/api/v1/mutes/index.ts
Normal file
74
api/api/v1/mutes/index.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { Timeline } from "~/packages/database-interface/timeline";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/mutes",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:mutes"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnMutes],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
max_id: z.string().regex(idValidator).optional(),
|
||||
since_id: z.string().regex(idValidator).optional(),
|
||||
min_id: z.string().regex(idValidator).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { max_id, since_id, limit, min_id } =
|
||||
context.req.valid("query");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
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,
|
||||
context.req.url,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
mutes.map((u) => u.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
99
api/api/v1/notifications/:id/dismiss.test.ts
Normal file
99
api/api/v1/notifications/:id/dismiss.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Notification as ApiNotification } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./dismiss";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
let notifications: ApiNotification[] = [];
|
||||
|
||||
// Create some test notifications: follow, favourite, reblog, mention
|
||||
beforeAll(async () => {
|
||||
await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[0].id}/follow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
notifications = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/notifications", config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
).then((r) => r.json());
|
||||
|
||||
expect(notifications.length).toBe(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/notifications/:id/dismiss
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", notifications[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should dismiss notification", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", notifications[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should not display dismissed notification", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL("/api/v1/notifications", config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const output = await response.json();
|
||||
|
||||
expect(output.length).toBe(0);
|
||||
});
|
||||
});
|
||||
54
api/api/v1/notifications/:id/dismiss.ts
Normal file
54
api/api/v1/notifications/:id/dismiss.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Notifications, RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/notifications/:id/dismiss",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:notifications"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnNotifications],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const { user } = context.req.valid("header");
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(Notifications)
|
||||
.set({
|
||||
dismissed: true,
|
||||
})
|
||||
.where(eq(Notifications.id, id));
|
||||
|
||||
return context.json({});
|
||||
},
|
||||
),
|
||||
);
|
||||
125
api/api/v1/notifications/:id/index.test.ts
Normal file
125
api/api/v1/notifications/:id/index.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Notification as ApiNotification } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
let notifications: ApiNotification[] = [];
|
||||
|
||||
// Create some test notifications: follow, favourite, reblog, mention
|
||||
beforeAll(async () => {
|
||||
await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[0].id}/follow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
notifications = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/notifications", config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
).then((r) => r.json());
|
||||
|
||||
expect(notifications.length).toBe(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/notifications/:id
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(
|
||||
":id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
config.http.base_url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 422 if ID is invalid", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", "invalid"),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("should return 404 if notification not found", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(
|
||||
":id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should return notification", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", notifications[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const notification = (await response.json()) as ApiNotification;
|
||||
|
||||
expect(notification).toBeDefined();
|
||||
expect(notification.id).toBe(notifications[0].id);
|
||||
expect(notification.type).toBe("follow");
|
||||
expect(notification.account).toBeDefined();
|
||||
expect(notification.account?.id).toBe(users[1].id);
|
||||
});
|
||||
});
|
||||
61
api/api/v1/notifications/:id/index.ts
Normal file
61
api/api/v1/notifications/:id/index.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { findManyNotifications } from "~/classes/functions/notification";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/notifications/:id",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:notifications"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnNotifications],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const { user } = context.req.valid("header");
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const notification = (
|
||||
await findManyNotifications(
|
||||
{
|
||||
where: (notification, { eq }) =>
|
||||
eq(notification.id, id),
|
||||
limit: 1,
|
||||
},
|
||||
user.id,
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (!notification) {
|
||||
return context.json({ error: "Notification not found" }, 404);
|
||||
}
|
||||
|
||||
return context.json(notification);
|
||||
},
|
||||
),
|
||||
);
|
||||
81
api/api/v1/notifications/clear/index.test.ts
Normal file
81
api/api/v1/notifications/clear/index.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Notification as ApiNotification } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
let notifications: ApiNotification[] = [];
|
||||
|
||||
// Create some test notifications: follow, favourite, reblog, mention
|
||||
beforeAll(async () => {
|
||||
await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[0].id}/follow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
notifications = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/notifications", config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
).then((r) => r.json());
|
||||
|
||||
expect(notifications.length).toBe(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/notifications/clear
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should clear notifications", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const newNotifications = await sendTestRequest(
|
||||
new Request(
|
||||
new URL("/api/v1/notifications", config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
).then((r) => r.json());
|
||||
|
||||
expect(newNotifications.length).toBe(0);
|
||||
});
|
||||
});
|
||||
43
api/api/v1/notifications/clear/index.ts
Normal file
43
api/api/v1/notifications/clear/index.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Notifications, RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/notifications/clear",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:notifications"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnNotifications],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user } = context.req.valid("header");
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(Notifications)
|
||||
.set({
|
||||
dismissed: true,
|
||||
})
|
||||
.where(eq(Notifications.notifiedId, user.id));
|
||||
|
||||
return context.json({});
|
||||
},
|
||||
),
|
||||
);
|
||||
123
api/api/v1/notifications/destroy_multiple/index.test.ts
Normal file
123
api/api/v1/notifications/destroy_multiple/index.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Notification as ApiNotification } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestStatuses, getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
const statuses = await getTestStatuses(40, users[0]);
|
||||
let notifications: ApiNotification[] = [];
|
||||
|
||||
// Create some test notifications
|
||||
beforeAll(async () => {
|
||||
await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[0].id}/follow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
for (const i of [0, 1, 2, 3]) {
|
||||
await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/statuses/${statuses[i].id}/favourite`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
notifications = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/notifications", config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
).then((r) => r.json());
|
||||
|
||||
expect(notifications.length).toBe(5);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/notifications/destroy_multiple
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?${new URLSearchParams(
|
||||
notifications.slice(1).map((n) => ["ids[]", n.id]),
|
||||
).toString()}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should dismiss notifications", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`${meta.route}?${new URLSearchParams(
|
||||
notifications.slice(1).map((n) => ["ids[]", n.id]),
|
||||
).toString()}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should not display dismissed notification", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL("/api/v1/notifications", config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const output = await response.json();
|
||||
|
||||
expect(output.length).toBe(1);
|
||||
});
|
||||
});
|
||||
60
api/api/v1/notifications/destroy_multiple/index.ts
Normal file
60
api/api/v1/notifications/destroy_multiple/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { Notifications, RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["DELETE"],
|
||||
route: "/api/v1/notifications/destroy_multiple",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["write:notifications"],
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnNotifications],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
"ids[]": z.array(z.string().uuid()),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const { "ids[]": ids } = context.req.valid("query");
|
||||
|
||||
await db
|
||||
.update(Notifications)
|
||||
.set({
|
||||
dismissed: true,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(Notifications.id, ids),
|
||||
eq(Notifications.notifiedId, user.id),
|
||||
),
|
||||
);
|
||||
|
||||
return context.json({});
|
||||
},
|
||||
),
|
||||
);
|
||||
200
api/api/v1/notifications/index.test.ts
Normal file
200
api/api/v1/notifications/index.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Notification as ApiNotification } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestStatuses, getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const getFormData = (object: Record<string, string | number | boolean>) =>
|
||||
Object.keys(object).reduce((formData, key) => {
|
||||
formData.append(key, String(object[key]));
|
||||
return formData;
|
||||
}, new FormData());
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
const timeline = (await getTestStatuses(40, users[0])).toReversed();
|
||||
// Create some test notifications: follow, favourite, reblog, mention
|
||||
beforeAll(async () => {
|
||||
const res1 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/accounts/${users[0].id}/follow`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
const res2 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/statuses/${timeline[0].id}/favourite`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(res2.status).toBe(200);
|
||||
|
||||
const res3 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/statuses/${timeline[0].id}/reblog`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
body: getFormData({}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(res3.status).toBe(200);
|
||||
|
||||
const res4 = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/statuses", config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
status: `@${users[0].data.username} test mention`,
|
||||
visibility: "direct",
|
||||
local_only: "true",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res4.status).toBe(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/notifications
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url)),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 200 with notifications", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"application/json",
|
||||
);
|
||||
|
||||
const objects = (await response.json()) as ApiNotification[];
|
||||
|
||||
expect(objects.length).toBe(4);
|
||||
for (const [index, notification] of objects.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
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("should not return notifications with filtered keywords", async () => {
|
||||
const filterResponse = await sendTestRequest(
|
||||
new Request(new URL("/api/v2/filters", config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
title: "Test Filter",
|
||||
"context[]": "notifications",
|
||||
filter_action: "hide",
|
||||
"keywords_attributes[0][keyword]":
|
||||
timeline[0].content.slice(4, 20),
|
||||
"keywords_attributes[0][whole_word]": "false",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(filterResponse.status).toBe(200);
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(`${meta.route}?limit=20`, config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"application/json",
|
||||
);
|
||||
|
||||
const objects = (await response.json()) as ApiNotification[];
|
||||
|
||||
expect(objects.length).toBe(2);
|
||||
// There should be no element with a status with id of timeline[0].id
|
||||
expect(objects).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: expect.objectContaining({ id: timeline[0].id }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Delete filter
|
||||
const filterDeleteResponse = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v2/filters/${(await filterResponse.json()).id}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(filterDeleteResponse.status).toBe(200);
|
||||
});
|
||||
});
|
||||
204
api/api/v1/notifications/index.ts
Normal file
204
api/api/v1/notifications/index.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { fetchTimeline } from "@/timelines";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
findManyNotifications,
|
||||
notificationToApi,
|
||||
} from "~/classes/functions/notification";
|
||||
import type { NotificationWithRelations } from "~/classes/functions/notification";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
route: "/api/v1/notifications",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:notifications"],
|
||||
},
|
||||
permissions: {
|
||||
required: [
|
||||
RolePermissions.ManageOwnNotifications,
|
||||
RolePermissions.ViewPrimateTimelines,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
max_id: z.string().regex(idValidator).optional(),
|
||||
since_id: z.string().regex(idValidator).optional(),
|
||||
min_id: z.string().regex(idValidator).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(15),
|
||||
exclude_types: z
|
||||
.enum([
|
||||
"mention",
|
||||
"status",
|
||||
"follow",
|
||||
"follow_request",
|
||||
"reblog",
|
||||
"poll",
|
||||
"favourite",
|
||||
"update",
|
||||
"admin.sign_up",
|
||||
"admin.report",
|
||||
"chat",
|
||||
"pleroma:chat_mention",
|
||||
"pleroma:emoji_reaction",
|
||||
"pleroma:event_reminder",
|
||||
"pleroma:participation_request",
|
||||
"pleroma:participation_accepted",
|
||||
"move",
|
||||
"group_reblog",
|
||||
"group_favourite",
|
||||
"user_approved",
|
||||
])
|
||||
.array()
|
||||
.optional(),
|
||||
types: z
|
||||
.enum([
|
||||
"mention",
|
||||
"status",
|
||||
"follow",
|
||||
"follow_request",
|
||||
"reblog",
|
||||
"poll",
|
||||
"favourite",
|
||||
"update",
|
||||
"admin.sign_up",
|
||||
"admin.report",
|
||||
"chat",
|
||||
"pleroma:chat_mention",
|
||||
"pleroma:emoji_reaction",
|
||||
"pleroma:event_reminder",
|
||||
"pleroma:participation_request",
|
||||
"pleroma:participation_accepted",
|
||||
"move",
|
||||
"group_reblog",
|
||||
"group_favourite",
|
||||
"user_approved",
|
||||
])
|
||||
.array()
|
||||
.optional(),
|
||||
account_id: z.string().regex(idValidator).optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user } = context.req.valid("header");
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const {
|
||||
account_id,
|
||||
exclude_types,
|
||||
limit,
|
||||
max_id,
|
||||
min_id,
|
||||
since_id,
|
||||
types,
|
||||
} = context.req.valid("query");
|
||||
|
||||
if (types && exclude_types) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Can't use both types and exclude_types",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const { objects, link } =
|
||||
await fetchTimeline<NotificationWithRelations>(
|
||||
findManyNotifications,
|
||||
{
|
||||
where: (
|
||||
// @ts-expect-error Yes I KNOW the types are wrong
|
||||
notification,
|
||||
// @ts-expect-error Yes I KNOW the types are wrong
|
||||
{ lt, gte, gt, and, eq, not, inArray },
|
||||
) =>
|
||||
and(
|
||||
max_id
|
||||
? lt(notification.id, max_id)
|
||||
: undefined,
|
||||
since_id
|
||||
? gte(notification.id, since_id)
|
||||
: undefined,
|
||||
min_id
|
||||
? gt(notification.id, min_id)
|
||||
: undefined,
|
||||
eq(notification.notifiedId, user.id),
|
||||
eq(notification.dismissed, false),
|
||||
account_id
|
||||
? eq(notification.accountId, account_id)
|
||||
: undefined,
|
||||
not(eq(notification.accountId, user.id)),
|
||||
types
|
||||
? inArray(notification.type, types)
|
||||
: undefined,
|
||||
exclude_types
|
||||
? not(
|
||||
inArray(
|
||||
notification.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,
|
||||
// @ts-expect-error Yes I KNOW the types are wrong
|
||||
orderBy: (notification, { desc }) =>
|
||||
desc(notification.id),
|
||||
},
|
||||
context.req.raw,
|
||||
user.id,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
await Promise.all(objects.map((n) => notificationToApi(n))),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
38
api/api/v1/profile/avatar.ts
Normal file
38
api/api/v1/profile/avatar.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["DELETE"],
|
||||
ratelimits: {
|
||||
max: 10,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/profile/avatar",
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnAccount],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user: self } = context.req.valid("header");
|
||||
|
||||
if (!self) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
await self.update({
|
||||
avatar: "",
|
||||
});
|
||||
|
||||
return context.json(self.toApi(true));
|
||||
},
|
||||
),
|
||||
);
|
||||
38
api/api/v1/profile/header.ts
Normal file
38
api/api/v1/profile/header.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["DELETE"],
|
||||
ratelimits: {
|
||||
max: 10,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/profile/header",
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnAccount],
|
||||
},
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user: self } = context.req.valid("header");
|
||||
|
||||
if (!self) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
await self.update({
|
||||
header: "",
|
||||
});
|
||||
|
||||
return context.json(self.toApi(true));
|
||||
},
|
||||
),
|
||||
);
|
||||
276
api/api/v1/roles/:id/index.test.ts
Normal file
276
api/api/v1/roles/:id/index.test.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ADMIN_ROLES, DEFAULT_ROLES, RolePermissions } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { Role } from "~/packages/database-interface/role";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(1);
|
||||
let role: Role;
|
||||
let roleNotLinked: Role;
|
||||
let higherPriorityRole: Role;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create new role
|
||||
role = await Role.insert({
|
||||
name: "test",
|
||||
permissions: DEFAULT_ROLES,
|
||||
priority: 2,
|
||||
description: "test",
|
||||
visible: true,
|
||||
icon: "test",
|
||||
});
|
||||
|
||||
expect(role).toBeDefined();
|
||||
|
||||
// Link role to user
|
||||
await role.linkUser(users[0].id);
|
||||
|
||||
// Create new role
|
||||
roleNotLinked = await Role.insert({
|
||||
name: "test2",
|
||||
permissions: ADMIN_ROLES,
|
||||
priority: 0,
|
||||
description: "test2",
|
||||
visible: true,
|
||||
icon: "test2",
|
||||
});
|
||||
|
||||
expect(roleNotLinked).toBeDefined();
|
||||
|
||||
// Create a role with higher priority than the user's role
|
||||
higherPriorityRole = await Role.insert({
|
||||
name: "higherPriorityRole",
|
||||
permissions: DEFAULT_ROLES,
|
||||
priority: 3, // Higher priority than the user's role
|
||||
description: "Higher priority role",
|
||||
visible: true,
|
||||
icon: "higherPriorityRole",
|
||||
});
|
||||
|
||||
expect(higherPriorityRole).toBeDefined();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await role.delete();
|
||||
await roleNotLinked.delete();
|
||||
await higherPriorityRole.delete();
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/roles/:id
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", role.id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 404 if role does not exist", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(
|
||||
":id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should return the role", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", role.id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const output = await response.json();
|
||||
expect(output).toMatchObject({
|
||||
name: "test",
|
||||
permissions: DEFAULT_ROLES,
|
||||
priority: 2,
|
||||
description: "test",
|
||||
visible: true,
|
||||
icon: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test("should return 403 if user does not have MANAGE_ROLES permission", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", roleNotLinked.id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const output = await response.json();
|
||||
expect(output).toMatchObject({
|
||||
error: "You do not have the required permissions to access this route. Missing: roles",
|
||||
});
|
||||
});
|
||||
|
||||
test("should assign new role", async () => {
|
||||
await role.update({
|
||||
permissions: [RolePermissions.ManageRoles],
|
||||
});
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", roleNotLinked.id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Check if role was assigned
|
||||
const response2 = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/roles", config.http.base_url), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response2.ok).toBe(true);
|
||||
const roles = await response2.json();
|
||||
// The default role will still be there
|
||||
expect(roles).toHaveLength(3);
|
||||
expect(roles).toContainEqual({
|
||||
id: roleNotLinked.id,
|
||||
name: "test2",
|
||||
permissions: ADMIN_ROLES,
|
||||
priority: 0,
|
||||
description: "test2",
|
||||
visible: true,
|
||||
icon: expect.any(String),
|
||||
});
|
||||
|
||||
await role.update({
|
||||
permissions: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("should unassign role", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", role.id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Check if role was unassigned
|
||||
const response2 = await sendTestRequest(
|
||||
new Request(new URL("/api/v1/roles", config.http.base_url), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response2.ok).toBe(true);
|
||||
const roles = await response2.json();
|
||||
// The default role will still be there
|
||||
expect(roles).toHaveLength(2);
|
||||
expect(roles).not.toContainEqual({
|
||||
name: "test",
|
||||
permissions: ADMIN_ROLES,
|
||||
priority: 0,
|
||||
description: "test",
|
||||
visible: true,
|
||||
icon: "test",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return 403 if user tries to add role with higher priority", async () => {
|
||||
// Add MANAGE_ROLES permission to user
|
||||
await role.update({
|
||||
permissions: [RolePermissions.ManageRoles],
|
||||
});
|
||||
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", higherPriorityRole.id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const output = await response.json();
|
||||
expect(output).toMatchObject({
|
||||
error: "Cannot assign role 'higherPriorityRole' with priority 3 to user with highest role priority 0",
|
||||
});
|
||||
|
||||
await role.update({
|
||||
permissions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
104
api/api/v1/roles/:id/index.ts
Normal file
104
api/api/v1/roles/:id/index.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { response } from "@/response";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Role } from "~/packages/database-interface/role";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET", "POST", "DELETE"],
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
ratelimits: {
|
||||
duration: 60,
|
||||
max: 20,
|
||||
},
|
||||
route: "/api/v1/roles/:id",
|
||||
permissions: {
|
||||
required: [],
|
||||
methodOverrides: {
|
||||
POST: [RolePermissions.ManageRoles],
|
||||
DELETE: [RolePermissions.ManageRoles],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user } = context.req.valid("header");
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const userRoles = await Role.getUserRoles(
|
||||
user.id,
|
||||
user.data.isAdmin,
|
||||
);
|
||||
const role = await Role.fromId(id);
|
||||
|
||||
if (!role) {
|
||||
return context.json({ error: "Role not found" }, 404);
|
||||
}
|
||||
|
||||
switch (context.req.method) {
|
||||
case "GET": {
|
||||
return context.json(role.toApi());
|
||||
}
|
||||
|
||||
case "POST": {
|
||||
const userHighestRole = userRoles.reduce((prev, current) =>
|
||||
prev.data.priority > current.data.priority
|
||||
? prev
|
||||
: current,
|
||||
);
|
||||
|
||||
if (role.data.priority > userHighestRole.data.priority) {
|
||||
return context.json(
|
||||
{
|
||||
error: `Cannot assign role '${role.data.name}' with priority ${role.data.priority} to user with highest role priority ${userHighestRole.data.priority}`,
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
await role.linkUser(user.id);
|
||||
|
||||
return response(null, 204);
|
||||
}
|
||||
case "DELETE": {
|
||||
const userHighestRole = userRoles.reduce((prev, current) =>
|
||||
prev.data.priority > current.data.priority
|
||||
? prev
|
||||
: current,
|
||||
);
|
||||
|
||||
if (role.data.priority > userHighestRole.data.priority) {
|
||||
return context.json(
|
||||
{
|
||||
error: `Cannot remove role '${role.data.name}' with priority ${role.data.priority} from user with highest role priority ${userHighestRole.data.priority}`,
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
await role.unlinkUser(user.id);
|
||||
|
||||
return response(null, 204);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
75
api/api/v1/roles/index.test.ts
Normal file
75
api/api/v1/roles/index.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ADMIN_ROLES } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { Role } from "~/packages/database-interface/role";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { users, deleteUsers, tokens } = await getTestUsers(1);
|
||||
let role: Role;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create new role
|
||||
role = await Role.insert({
|
||||
name: "test",
|
||||
permissions: ADMIN_ROLES,
|
||||
priority: 0,
|
||||
description: "test",
|
||||
visible: true,
|
||||
icon: "test",
|
||||
});
|
||||
|
||||
expect(role).toBeDefined();
|
||||
|
||||
// Link role to user
|
||||
await role.linkUser(users[0].id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/roles
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "GET",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return a list of roles", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const roles = await response.json();
|
||||
expect(roles).toHaveLength(2);
|
||||
expect(roles[0]).toMatchObject({
|
||||
name: "test",
|
||||
permissions: ADMIN_ROLES,
|
||||
priority: 0,
|
||||
description: "test",
|
||||
visible: true,
|
||||
icon: expect.any(String),
|
||||
});
|
||||
|
||||
expect(roles[1]).toMatchObject({
|
||||
name: "Default",
|
||||
permissions: config.permissions.default,
|
||||
priority: 0,
|
||||
description: "Default role for all users",
|
||||
visible: false,
|
||||
icon: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
36
api/api/v1/roles/index.ts
Normal file
36
api/api/v1/roles/index.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { apiRoute, applyConfig, auth } from "@/api";
|
||||
import { Role } from "~/packages/database-interface/role";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
ratelimits: {
|
||||
duration: 60,
|
||||
max: 20,
|
||||
},
|
||||
route: "/api/v1/roles",
|
||||
});
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const userRoles = await Role.getUserRoles(
|
||||
user.id,
|
||||
user.data.isAdmin,
|
||||
);
|
||||
|
||||
return context.json(userRoles.map((r) => r.toApi()));
|
||||
},
|
||||
),
|
||||
);
|
||||
61
api/api/v1/sso/:id/index.test.ts
Normal file
61
api/api/v1/sso/:id/index.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { deleteUsers, tokens } = await getTestUsers(1);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/sso/:id
|
||||
describe(meta.route, () => {
|
||||
test("should not find unknown issuer", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", "unknown"),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0]?.accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({
|
||||
error: "Issuer not found",
|
||||
});
|
||||
|
||||
const response2 = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", "unknown"),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0]?.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response2.status).toBe(404);
|
||||
expect(await response2.json()).toMatchObject({
|
||||
error: "Issuer not found",
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
Unfortunately, we cannot test actual linking, as it requires a valid OpenID provider
|
||||
setup in config, which we don't have in tests
|
||||
*/
|
||||
});
|
||||
111
api/api/v1/sso/:id/index.ts
Normal file
111
api/api/v1/sso/:id/index.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { proxyUrl, response } from "@/response";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { OpenIdAccounts, RolePermissions } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET", "DELETE"],
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
ratelimits: {
|
||||
duration: 60,
|
||||
max: 20,
|
||||
},
|
||||
route: "/api/v1/sso/:id",
|
||||
permissions: {
|
||||
required: [RolePermissions.OAuth],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* SSO Account Linking management endpoint
|
||||
* A GET request allows the user to list all their linked accounts
|
||||
* A POST request allows the user to link a new account
|
||||
*/
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id: issuerId } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const issuer = config.oidc.providers.find(
|
||||
(provider) => provider.id === issuerId,
|
||||
);
|
||||
|
||||
if (!issuer) {
|
||||
return context.json({ error: "Issuer not found" }, 404);
|
||||
}
|
||||
|
||||
switch (context.req.method) {
|
||||
case "GET": {
|
||||
// Get all linked accounts
|
||||
const account = await db.query.OpenIdAccounts.findFirst({
|
||||
where: (account, { eq, and }) =>
|
||||
and(
|
||||
eq(account.userId, account.id),
|
||||
eq(account.issuerId, issuerId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Account not found or is not linked to this issuer",
|
||||
},
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
return context.json({
|
||||
id: issuer.id,
|
||||
name: issuer.name,
|
||||
icon: proxyUrl(issuer.icon) || undefined,
|
||||
});
|
||||
}
|
||||
case "DELETE": {
|
||||
const account = await db.query.OpenIdAccounts.findFirst({
|
||||
where: (account, { eq, and }) =>
|
||||
and(
|
||||
eq(account.userId, user.id),
|
||||
eq(account.issuerId, issuerId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Account not found or is not linked to this issuer",
|
||||
},
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(OpenIdAccounts)
|
||||
.where(eq(OpenIdAccounts.id, account.id));
|
||||
|
||||
return response(null, 204);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
52
api/api/v1/sso/index.test.ts
Normal file
52
api/api/v1/sso/index.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
const { deleteUsers, tokens } = await getTestUsers(1);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/sso
|
||||
describe(meta.route, () => {
|
||||
test("should return empty list", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0]?.accessToken}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject([]);
|
||||
});
|
||||
|
||||
test("should return an error if provider doesn't exist", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0]?.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
issuer: "unknown",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({
|
||||
error: "Issuer unknown not found",
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
Unfortunately, we cannot test actual linking, as it requires a valid OpenID provider
|
||||
setup in config, which we don't have in tests
|
||||
*/
|
||||
});
|
||||
183
api/api/v1/sso/index.ts
Normal file
183
api/api/v1/sso/index.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api";
|
||||
import { oauthRedirectUri } from "@/constants";
|
||||
import { randomString } from "@/math";
|
||||
import { proxyUrl } from "@/response";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import {
|
||||
calculatePKCECodeChallenge,
|
||||
discoveryRequest,
|
||||
generateRandomCodeVerifier,
|
||||
processDiscoveryResponse,
|
||||
} from "oauth4webapi";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/drizzle/db";
|
||||
import {
|
||||
Applications,
|
||||
OpenIdLoginFlows,
|
||||
RolePermissions,
|
||||
} from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET", "POST"],
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
ratelimits: {
|
||||
duration: 60,
|
||||
max: 20,
|
||||
},
|
||||
route: "/api/v1/sso",
|
||||
permissions: {
|
||||
required: [RolePermissions.OAuth],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
json: z
|
||||
.object({
|
||||
issuer: z.string(),
|
||||
})
|
||||
.partial(),
|
||||
};
|
||||
|
||||
/**
|
||||
* SSO Account Linking management endpoint
|
||||
* A GET request allows the user to list all their linked accounts
|
||||
* A POST request allows the user to link a new account, and returns a link
|
||||
*/
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
jsonOrForm(),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const form = context.req.valid("json");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
switch (context.req.method) {
|
||||
case "GET": {
|
||||
// Get all linked accounts
|
||||
const accounts = await db.query.OpenIdAccounts.findMany({
|
||||
where: (User, { eq }) => eq(User.userId, user.id),
|
||||
});
|
||||
|
||||
return context.json(
|
||||
accounts
|
||||
.map((account) => {
|
||||
const issuer = config.oidc.providers.find(
|
||||
(provider) =>
|
||||
provider.id === account.issuerId,
|
||||
);
|
||||
|
||||
if (!issuer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: issuer.id,
|
||||
name: issuer.name,
|
||||
icon: proxyUrl(issuer.icon) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | undefined;
|
||||
}[],
|
||||
);
|
||||
}
|
||||
case "POST": {
|
||||
if (!form) {
|
||||
return context.json(
|
||||
{ error: "Missing issuer in form body" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const { issuer: issuerId } = form;
|
||||
|
||||
if (!issuerId) {
|
||||
return context.json(
|
||||
{ error: "Missing issuer in form body" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const issuer = config.oidc.providers.find(
|
||||
(provider) => provider.id === issuerId,
|
||||
);
|
||||
|
||||
if (!issuer) {
|
||||
return context.json(
|
||||
{ error: `Issuer ${issuerId} not found` },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
const issuerUrl = new URL(issuer.url);
|
||||
|
||||
const authServer = await discoveryRequest(issuerUrl, {
|
||||
algorithm: "oidc",
|
||||
}).then((res) => processDiscoveryResponse(issuerUrl, res));
|
||||
|
||||
const codeVerifier = generateRandomCodeVerifier();
|
||||
|
||||
const application = (
|
||||
await db
|
||||
.insert(Applications)
|
||||
.values({
|
||||
clientId: user.id + randomString(32, "base64"),
|
||||
name: "Versia",
|
||||
redirectUri: `${oauthRedirectUri(issuerId)}`,
|
||||
scopes: "openid profile email",
|
||||
secret: "",
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
// Store into database
|
||||
const newFlow = (
|
||||
await db
|
||||
.insert(OpenIdLoginFlows)
|
||||
.values({
|
||||
codeVerifier,
|
||||
issuerId,
|
||||
applicationId: application.id,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
const codeChallenge =
|
||||
await calculatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
return context.json({
|
||||
link: `${
|
||||
authServer.authorization_endpoint
|
||||
}?${new URLSearchParams({
|
||||
client_id: issuer.client_id,
|
||||
redirect_uri: `${oauthRedirectUri(
|
||||
issuerId,
|
||||
)}?${new URLSearchParams({
|
||||
flow: newFlow.id,
|
||||
link: "true",
|
||||
user_id: user.id,
|
||||
})}`,
|
||||
response_type: "code",
|
||||
scope: "openid profile email",
|
||||
// PKCE
|
||||
code_challenge_method: "S256",
|
||||
code_challenge: codeChallenge,
|
||||
}).toString()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
59
api/api/v1/statuses/:id/context.ts
Normal file
59
api/api/v1/statuses/:id/context.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Note } from "~/packages/database-interface/note";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 8,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/statuses/:id/context",
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ViewNotes],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
const foundStatus = await Note.fromId(id, user?.id);
|
||||
|
||||
if (!foundStatus) {
|
||||
return context.json({ error: "Record not found" }, 404);
|
||||
}
|
||||
|
||||
const ancestors = await foundStatus.getAncestors(user ?? null);
|
||||
|
||||
const descendants = await foundStatus.getDescendants(user ?? null);
|
||||
|
||||
return context.json({
|
||||
ancestors: await Promise.all(
|
||||
ancestors.map((status) => status.toApi(user)),
|
||||
),
|
||||
descendants: await Promise.all(
|
||||
descendants.map((status) => status.toApi(user)),
|
||||
),
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
78
api/api/v1/statuses/:id/favourite.test.ts
Normal file
78
api/api/v1/statuses/:id/favourite.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import type { Status as ApiStatus } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestStatuses, getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./favourite";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
const timeline = (await getTestStatuses(2, users[0])).toReversed();
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/statuses/:id/favourite
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", timeline[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should favourite post", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", timeline[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = (await response.json()) as ApiStatus;
|
||||
|
||||
expect(json.favourited).toBe(true);
|
||||
expect(json.favourites_count).toBe(1);
|
||||
});
|
||||
|
||||
test("post should be favourited when fetched", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/statuses/${timeline[0].id}`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = (await response.json()) as ApiStatus;
|
||||
|
||||
expect(json.favourited).toBe(true);
|
||||
expect(json.favourites_count).toBe(1);
|
||||
});
|
||||
});
|
||||
72
api/api/v1/statuses/:id/favourite.ts
Normal file
72
api/api/v1/statuses/:id/favourite.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { apiRoute, applyConfig, auth, handleZodError } from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { createLike } from "~/classes/functions/like";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { Note } from "~/packages/database-interface/note";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/statuses/:id/favourite",
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ManageOwnLikes, RolePermissions.ViewNotes],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const note = await Note.fromId(id, user?.id);
|
||||
|
||||
if (!note?.isViewableByUser(user)) {
|
||||
return context.json({ error: "Record not found" }, 404);
|
||||
}
|
||||
|
||||
const existingLike = await db.query.Likes.findFirst({
|
||||
where: (like, { and, eq }) =>
|
||||
and(
|
||||
eq(like.likedId, note.data.id),
|
||||
eq(like.likerId, user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingLike) {
|
||||
await createLike(user, note);
|
||||
}
|
||||
|
||||
const newNote = await Note.fromId(id, user.id);
|
||||
|
||||
if (!newNote) {
|
||||
return context.json({ error: "Record not found" }, 404);
|
||||
}
|
||||
|
||||
return context.json(await newNote.toApi(user));
|
||||
},
|
||||
),
|
||||
);
|
||||
76
api/api/v1/statuses/:id/favourited_by.test.ts
Normal file
76
api/api/v1/statuses/:id/favourited_by.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Account as ApiAccount } from "@versia/client/types";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { getTestStatuses, getTestUsers, sendTestRequest } from "~/tests/utils";
|
||||
import { meta } from "./favourited_by";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
const timeline = (await getTestStatuses(40, users[0])).toReversed();
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const status of timeline) {
|
||||
await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
`/api/v1/statuses/${status.id}/favourite`,
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// /api/v1/statuses/:id/favourited_by
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", timeline[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 200 with users", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(
|
||||
meta.route.replace(":id", timeline[0].id),
|
||||
config.http.base_url,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain(
|
||||
"application/json",
|
||||
);
|
||||
|
||||
const objects = (await response.json()) as ApiAccount[];
|
||||
|
||||
expect(objects.length).toBe(1);
|
||||
for (const [, status] of objects.entries()) {
|
||||
expect(status.id).toBe(users[1].id);
|
||||
expect(status.username).toBe(users[1].data.username);
|
||||
}
|
||||
});
|
||||
});
|
||||
86
api/api/v1/statuses/:id/favourited_by.ts
Normal file
86
api/api/v1/statuses/:id/favourited_by.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions, Users } from "~/drizzle/schema";
|
||||
import { Note } from "~/packages/database-interface/note";
|
||||
import { Timeline } from "~/packages/database-interface/timeline";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET"],
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/statuses/:id/favourited_by",
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ViewNotes, RolePermissions.ViewNoteLikes],
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
max_id: z.string().regex(idValidator).optional(),
|
||||
since_id: z.string().regex(idValidator).optional(),
|
||||
min_id: z.string().regex(idValidator).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
||||
}),
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { max_id, since_id, min_id, limit } =
|
||||
context.req.valid("query");
|
||||
const { id } = context.req.valid("param");
|
||||
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const status = await Note.fromId(id, user?.id);
|
||||
|
||||
if (!status?.isViewableByUser(user)) {
|
||||
return context.json({ error: "Record not found" }, 404);
|
||||
}
|
||||
|
||||
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 "Likes" WHERE "Likes"."likedId" = ${status.id} AND "Likes"."likerId" = ${Users.id})`,
|
||||
),
|
||||
limit,
|
||||
context.req.url,
|
||||
);
|
||||
|
||||
return context.json(
|
||||
objects.map((user) => user.toApi()),
|
||||
200,
|
||||
{
|
||||
Link: link,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
183
api/api/v1/statuses/:id/index.ts
Normal file
183
api/api/v1/statuses/:id/index.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import {
|
||||
apiRoute,
|
||||
applyConfig,
|
||||
auth,
|
||||
handleZodError,
|
||||
idValidator,
|
||||
jsonOrForm,
|
||||
} from "@/api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import ISO6391 from "iso-639-1";
|
||||
import { z } from "zod";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import { Attachment } from "~/packages/database-interface/attachment";
|
||||
import { Note } from "~/packages/database-interface/note";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET", "DELETE", "PUT"],
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/api/v1/statuses/:id",
|
||||
auth: {
|
||||
required: false,
|
||||
methodOverrides: {
|
||||
DELETE: true,
|
||||
PUT: true,
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
required: [RolePermissions.ViewNotes],
|
||||
methodOverrides: {
|
||||
DELETE: [RolePermissions.ManageOwnNotes, RolePermissions.ViewNotes],
|
||||
PUT: [RolePermissions.ManageOwnNotes, RolePermissions.ViewNotes],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().regex(idValidator),
|
||||
}),
|
||||
json: z
|
||||
.object({
|
||||
status: z
|
||||
.string()
|
||||
.max(config.validation.max_note_size)
|
||||
.refine(
|
||||
(s) =>
|
||||
!config.filters.note_content.some((filter) =>
|
||||
s.match(filter),
|
||||
),
|
||||
"Status contains blocked words",
|
||||
)
|
||||
.optional(),
|
||||
content_type: z.string().optional().default("text/plain"),
|
||||
media_ids: z
|
||||
.array(z.string().regex(idValidator))
|
||||
.max(config.validation.max_media_attachments)
|
||||
.default([]),
|
||||
spoiler_text: z.string().max(255).optional(),
|
||||
sensitive: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.or(z.boolean())
|
||||
.optional(),
|
||||
language: z
|
||||
.enum(ISO6391.getAllCodes() as [string, ...string[]])
|
||||
.optional(),
|
||||
"poll[options]": z
|
||||
.array(z.string().max(config.validation.max_poll_option_size))
|
||||
.max(config.validation.max_poll_options)
|
||||
.optional(),
|
||||
"poll[expires_in]": z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(config.validation.min_poll_duration)
|
||||
.max(config.validation.max_poll_duration)
|
||||
.optional(),
|
||||
"poll[multiple]": z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.or(z.boolean())
|
||||
.optional(),
|
||||
"poll[hide_totals]": z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
.or(z.boolean())
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(obj) => !(obj.media_ids.length > 0 && obj["poll[options]"]),
|
||||
"Cannot attach poll to media",
|
||||
),
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
jsonOrForm(),
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
auth(meta.auth, meta.permissions),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
// TODO: Polls
|
||||
const {
|
||||
status: statusText,
|
||||
content_type,
|
||||
media_ids,
|
||||
spoiler_text,
|
||||
sensitive,
|
||||
} = context.req.valid("json");
|
||||
|
||||
const note = await Note.fromId(id, user?.id);
|
||||
|
||||
if (!note?.isViewableByUser(user)) {
|
||||
return context.json({ error: "Record not found" }, 404);
|
||||
}
|
||||
|
||||
switch (context.req.method) {
|
||||
case "GET": {
|
||||
return context.json(await note.toApi(user));
|
||||
}
|
||||
case "DELETE": {
|
||||
if (note.author.id !== user?.id) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// TODO: Delete and redraft
|
||||
|
||||
await note.delete();
|
||||
|
||||
await user.federateToFollowers(note.deleteToVersia());
|
||||
|
||||
return context.json(await note.toApi(user), 200);
|
||||
}
|
||||
case "PUT": {
|
||||
if (!user) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
if (note.author.id !== user.id) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
if (media_ids.length > 0) {
|
||||
const foundAttachments =
|
||||
await Attachment.fromIds(media_ids);
|
||||
|
||||
if (foundAttachments.length !== media_ids.length) {
|
||||
return context.json(
|
||||
{ error: "Invalid media IDs" },
|
||||
422,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newNote = await note.updateFromData({
|
||||
author: user,
|
||||
content: statusText
|
||||
? {
|
||||
[content_type]: {
|
||||
content: statusText,
|
||||
remote: false,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
isSensitive: sensitive,
|
||||
spoilerText: spoiler_text,
|
||||
mediaAttachments: media_ids,
|
||||
});
|
||||
|
||||
return context.json(await newNote.toApi(user));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue