feat(api): Add new admin emoji API

This commit is contained in:
Jesse Wierzbinski 2024-05-11 15:27:28 -10:00
parent b979daa39a
commit 8fedd1a07d
No known key found for this signature in database
20 changed files with 954 additions and 167 deletions

View file

@ -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");

View 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);
});
});

View 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));
}
}
},
);

View 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",
),
);
});
});

View 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,
}),
);
},
);