mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 22:09:16 +01:00
feat(api): ✨ Add new admin emoji API
This commit is contained in:
parent
b979daa39a
commit
8fedd1a07d
20 changed files with 954 additions and 167 deletions
|
|
@ -6,7 +6,11 @@ import {
|
|||
expect,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { config } from "config-manager";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Users } from "~drizzle/schema";
|
||||
import {
|
||||
deleteOldTestUsers,
|
||||
getTestStatuses,
|
||||
|
|
@ -15,10 +19,6 @@ import {
|
|||
} from "~tests/utils";
|
||||
import type { Account as APIAccount } from "~types/mastodon/account";
|
||||
import { meta } from "./index";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { db } from "~drizzle/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Users } from "~drizzle/schema";
|
||||
|
||||
const username = randomBytes(10).toString("hex");
|
||||
const username2 = randomBytes(10).toString("hex");
|
||||
|
|
|
|||
168
server/api/api/v1/emojis/:id/index.test.ts
Normal file
168
server/api/api/v1/emojis/:id/index.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "config-manager";
|
||||
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.lysand.org/logo.webp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
id = emoji.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /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 return 403 if not an admin", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
new URL(meta.route.replace(":id", id), config.http.base_url),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
177
server/api/api/v1/emojis/:id/index.ts
Normal file
177
server/api/api/v1/emojis/:id/index.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import {
|
||||
applyConfig,
|
||||
auth,
|
||||
emojiValidator,
|
||||
handleZodError,
|
||||
jsonOrForm,
|
||||
} from "@api";
|
||||
import { mimeLookup } from "@content_types";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { errorResponse, jsonResponse, response } from "@response";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { getUrl } from "~database/entities/Attachment";
|
||||
import { emojiToAPI } from "~database/entities/Emoji";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Emojis } from "~drizzle/schema";
|
||||
import { config } from "~packages/config-manager";
|
||||
import { MediaBackend } from "~packages/media-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["DELETE", "GET", "PATCH"],
|
||||
route: "/api/v1/emojis/:id",
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
param: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
form: 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)),
|
||||
alt: z.string().max(1000).optional(),
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
};
|
||||
|
||||
export default (app: Hono) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
jsonOrForm(),
|
||||
zValidator("param", schemas.param, handleZodError),
|
||||
zValidator("form", schemas.form, handleZodError),
|
||||
auth(meta.auth),
|
||||
async (context) => {
|
||||
const { id } = context.req.valid("param");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
// Check if user is admin
|
||||
if (!user?.getUser().isAdmin) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: "You do not have permission to modify emojis (must be an administrator)",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const emoji = await db.query.Emojis.findFirst({
|
||||
where: (emoji, { eq }) => eq(emoji.id, id),
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!emoji) return errorResponse("Emoji not found", 404);
|
||||
|
||||
switch (context.req.method) {
|
||||
case "DELETE": {
|
||||
await db.delete(Emojis).where(eq(Emojis.id, id));
|
||||
|
||||
return response(null, 204);
|
||||
}
|
||||
|
||||
case "PATCH": {
|
||||
const form = context.req.valid("form");
|
||||
|
||||
if (!form) {
|
||||
return errorResponse(
|
||||
"Invalid form data (must supply shortcode and/or element and/or alt)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (!form.shortcode && !form.element && !form.alt) {
|
||||
return errorResponse(
|
||||
"Invalid form data (must supply shortcode and/or element and/or alt)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (form.element) {
|
||||
// Check of emoji is an image
|
||||
const contentType =
|
||||
form.element instanceof File
|
||||
? form.element.type
|
||||
: await mimeLookup(form.element);
|
||||
|
||||
if (!contentType.startsWith("image/")) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
let url = "";
|
||||
|
||||
if (form.element instanceof File) {
|
||||
const media = await MediaBackend.fromBackendType(
|
||||
config.media.backend,
|
||||
config,
|
||||
);
|
||||
|
||||
const uploaded = await media.addFile(form.element);
|
||||
|
||||
url = uploaded.path;
|
||||
} else {
|
||||
url = form.element;
|
||||
}
|
||||
|
||||
emoji.url = getUrl(url, config);
|
||||
emoji.contentType = contentType;
|
||||
}
|
||||
|
||||
const newEmoji = (
|
||||
await db
|
||||
.update(Emojis)
|
||||
.set({
|
||||
shortcode: form.shortcode ?? emoji.shortcode,
|
||||
alt: form.alt ?? emoji.alt,
|
||||
url: emoji.url,
|
||||
contentType: emoji.contentType,
|
||||
})
|
||||
.where(eq(Emojis.id, id))
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
return jsonResponse(
|
||||
emojiToAPI({
|
||||
...newEmoji,
|
||||
instance: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
case "GET": {
|
||||
return jsonResponse(emojiToAPI(emoji));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
116
server/api/api/v1/emojis/index.test.ts
Normal file
116
server/api/api/v1/emojis/index.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "config-manager";
|
||||
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 });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
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.lysand.org/logo.webp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return 403 if not an admin", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
shortcode: "test",
|
||||
element: "https://cdn.lysand.org/logo.webp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
test("should upload a file and create an emoji", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("shortcode", "test");
|
||||
formData.append("element", Bun.file("tests/test-image.webp"));
|
||||
|
||||
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("test");
|
||||
expect(emoji.url).toContain("/media/proxy");
|
||||
});
|
||||
|
||||
test("should try to upload a non-image", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("shortcode", "test");
|
||||
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: "test2",
|
||||
element: "https://cdn.lysand.org/logo.webp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const emoji = await response.json();
|
||||
expect(emoji.shortcode).toBe("test2");
|
||||
expect(emoji.url).toContain(
|
||||
Buffer.from("https://cdn.lysand.org/logo.webp").toString(
|
||||
"base64url",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
125
server/api/api/v1/emojis/index.ts
Normal file
125
server/api/api/v1/emojis/index.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
applyConfig,
|
||||
auth,
|
||||
emojiValidator,
|
||||
handleZodError,
|
||||
jsonOrForm,
|
||||
} from "@api";
|
||||
import { mimeLookup } from "@content_types";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { jsonResponse } from "@response";
|
||||
import type { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { getUrl } from "~database/entities/Attachment";
|
||||
import { emojiToAPI } from "~database/entities/Emoji";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Emojis } from "~drizzle/schema";
|
||||
import { config } from "~packages/config-manager";
|
||||
import { MediaBackend } from "~packages/media-manager";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
route: "/api/v1/emojis",
|
||||
ratelimits: {
|
||||
max: 30,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
form: 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)),
|
||||
alt: z.string().max(1000).optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default (app: Hono) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
jsonOrForm(),
|
||||
zValidator("form", schemas.form, handleZodError),
|
||||
auth(meta.auth),
|
||||
async (context) => {
|
||||
const { shortcode, element, alt } = context.req.valid("form");
|
||||
const { user } = context.req.valid("header");
|
||||
|
||||
// Check if user is admin
|
||||
if (!user?.getUser().isAdmin) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: "You do not have permission to add emojis (must be an administrator)",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
let url = "";
|
||||
|
||||
// Check of emoji is an image
|
||||
const contentType =
|
||||
element instanceof File
|
||||
? element.type
|
||||
: await mimeLookup(element);
|
||||
|
||||
if (!contentType.startsWith("image/")) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (element instanceof File) {
|
||||
const media = await MediaBackend.fromBackendType(
|
||||
config.media.backend,
|
||||
config,
|
||||
);
|
||||
|
||||
const uploaded = await media.addFile(element);
|
||||
|
||||
url = uploaded.path;
|
||||
} else {
|
||||
url = element;
|
||||
}
|
||||
|
||||
const emoji = (
|
||||
await db
|
||||
.insert(Emojis)
|
||||
.values({
|
||||
shortcode,
|
||||
url: getUrl(url, config),
|
||||
visibleInPicker: true,
|
||||
contentType,
|
||||
alt,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
return jsonResponse(
|
||||
emojiToAPI({
|
||||
...emoji,
|
||||
instance: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue