feat(api): Add user emoji upload capabilities

This commit is contained in:
Jesse Wierzbinski 2024-05-12 16:09:57 -10:00
parent 980f4c8021
commit da2520e60e
No known key found for this signature in database
36 changed files with 5440 additions and 3340 deletions

View file

@ -0,0 +1,162 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { inArray } from "drizzle-orm";
import { db } from "~drizzle/db";
import { Emojis } from "~drizzle/schema";
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.lysand.org/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.lysand.org/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.lysand.org/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")).toBe("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")).toBe("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")).toBe("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",
}),
);
});
});

View file

@ -1,4 +1,4 @@
import { applyConfig } from "@api";
import { applyConfig, auth } from "@api";
import { jsonResponse } from "@response";
import type { Hono } from "hono";
import { emojiToAPI } from "~database/entities/Emoji";
@ -17,15 +17,29 @@ export const meta = applyConfig({
});
export default (app: Hono) =>
app.on(meta.allowedMethods, meta.route, async () => {
const emojis = await db.query.Emojis.findMany({
where: (emoji, { isNull }) => isNull(emoji.instanceId),
with: {
instance: true,
},
});
app.on(
meta.allowedMethods,
meta.route,
auth(meta.auth),
async (context) => {
const { user } = context.req.valid("header");
return jsonResponse(
await Promise.all(emojis.map((emoji) => emojiToAPI(emoji))),
);
});
const emojis = await db.query.Emojis.findMany({
where: (emoji, { isNull, and, eq, or }) =>
and(
isNull(emoji.instanceId),
or(
isNull(emoji.ownerId),
user ? eq(emoji.ownerId, user.id) : undefined,
),
),
with: {
instance: true,
},
});
return jsonResponse(
await Promise.all(emojis.map((emoji) => emojiToAPI(emoji))),
);
},
);

View file

@ -1,5 +1,8 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { inArray } from "drizzle-orm";
import { db } from "~drizzle/db";
import { Emojis } from "~drizzle/schema";
import { getTestUsers, sendTestRequest } from "~tests/utils";
import { meta } from "./index";
@ -21,6 +24,7 @@ beforeAll(async () => {
body: JSON.stringify({
shortcode: "test",
element: "https://cdn.lysand.org/logo.webp",
global: true,
}),
}),
);
@ -32,6 +36,10 @@ beforeAll(async () => {
afterAll(async () => {
await deleteUsers();
await db
.delete(Emojis)
.where(inArray(Emojis.shortcode, ["test", "test2", "test3", "test4"]));
});
// /api/v1/emojis/:id (PATCH, DELETE, GET)
@ -71,15 +79,19 @@ describe(meta.route, () => {
expect(response.status).toBe(404);
});
test("should return 403 if not an admin", async () => {
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: "GET",
method: "PATCH",
body: JSON.stringify({
shortcode: "test2",
}),
},
),
);
@ -150,6 +162,43 @@ describe(meta.route, () => {
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(

View file

@ -52,7 +52,13 @@ export const schemas = {
.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(),
@ -74,16 +80,6 @@ export default (app: Hono) =>
return errorResponse("Unauthorized", 401);
}
// 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: {
@ -93,6 +89,19 @@ export default (app: Hono) =>
if (!emoji) return errorResponse("Emoji not found", 404);
// Check if user is admin
if (
!user.getUser().isAdmin &&
emoji.ownerId !== user.getUser().id
) {
return jsonResponse(
{
error: "You do not have permission to modify this emoji, as it is either global or not owned by you",
},
403,
);
}
switch (context.req.method) {
case "DELETE": {
await db.delete(Emojis).where(eq(Emojis.id, id));
@ -105,18 +114,31 @@ export default (app: Hono) =>
if (!form) {
return errorResponse(
"Invalid form data (must supply shortcode and/or element and/or alt)",
"Invalid form data (must supply at least one of: shortcode, element, alt, category)",
422,
);
}
if (!form.shortcode && !form.element && !form.alt) {
if (
!form.shortcode &&
!form.element &&
!form.alt &&
!form.category &&
form.global === undefined
) {
return errorResponse(
"Invalid form data (must supply shortcode and/or element and/or alt)",
"Invalid form data (must supply shortcode and/or element and/or alt and/or global)",
422,
);
}
if (!user.getUser().isAdmin && form.global) {
return errorResponse(
"Only administrators can make an emoji global or not",
401,
);
}
if (form.element) {
// Check of emoji is an image
const contentType =
@ -159,7 +181,9 @@ export default (app: Hono) =>
shortcode: form.shortcode ?? emoji.shortcode,
alt: form.alt ?? emoji.alt,
url: emoji.url,
ownerId: form.global ? null : user.id,
contentType: emoji.contentType,
category: form.category ?? emoji.category,
})
.where(eq(Emojis.id, id))
.returning()

View file

@ -6,7 +6,7 @@ import { Emojis } from "~drizzle/schema";
import { getTestUsers, sendTestRequest } from "~tests/utils";
import { meta } from "./index";
const { users, tokens, deleteUsers } = await getTestUsers(2);
const { users, tokens, deleteUsers } = await getTestUsers(3);
// Make user 2 an admin
beforeAll(async () => {
@ -18,7 +18,7 @@ afterAll(async () => {
await db
.delete(Emojis)
.where(inArray(Emojis.shortcode, ["test1", "test2", "test3"]));
.where(inArray(Emojis.shortcode, ["test1", "test2", "test3", "test4"]));
});
describe(meta.route, () => {
@ -39,99 +39,146 @@ describe(meta.route, () => {
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",
describe("Admin tests", () => {
test("should upload a file and create an emoji", async () => {
const formData = new FormData();
formData.append("shortcode", "test1");
formData.append("element", Bun.file("tests/test-image.webp"));
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.status).toBe(403);
});
expect(response.ok).toBe(true);
const emoji = await response.json();
expect(emoji.shortcode).toBe("test1");
expect(emoji.url).toContain("/media/proxy");
});
test("should upload a file and create an emoji", async () => {
const formData = new FormData();
formData.append("shortcode", "test1");
formData.append("element", Bun.file("tests/test-image.webp"));
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.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.lysand.org/logo.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("test3");
expect(emoji.url).toContain("/media/proxy/");
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.lysand.org/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", 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.status).toBe(422);
});
});
test("should fail when uploading an already existing emoji", async () => {
const formData = new FormData();
formData.append("shortcode", "test1");
formData.append("element", Bun.file("tests/test-image.webp"));
describe("User tests", () => {
test("should upload a file and create an emoji", async () => {
const formData = new FormData();
formData.append("shortcode", "test4");
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,
}),
);
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);
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", 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[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", 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[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/");
});
});
});

View file

@ -47,7 +47,13 @@ export const schemas = {
.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(),
}),
};
@ -59,31 +65,34 @@ export default (app: Hono) =>
zValidator("form", schemas.form, handleZodError),
auth(meta.auth),
async (context) => {
const { shortcode, element, alt } = context.req.valid("form");
const { shortcode, element, alt, global, category } =
context.req.valid("form");
const { user } = context.req.valid("header");
if (!user) {
return errorResponse("Unauthorized", 401);
}
// 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,
if (!user.getUser().isAdmin && global) {
return errorResponse(
"Only administrators can upload global emojis",
401,
);
}
// Check if emoji already exists
const existing = await db.query.Emojis.findFirst({
where: (emoji, { eq }) => eq(emoji.shortcode, shortcode),
where: (emoji, { eq, and, isNull, or }) =>
and(
eq(emoji.shortcode, shortcode),
isNull(emoji.instanceId),
or(eq(emoji.ownerId, user.id), isNull(emoji.ownerId)),
),
});
if (existing) {
return errorResponse(
`An emoji with the shortcode ${shortcode} already exists.`,
`An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
422,
);
}
@ -123,6 +132,8 @@ export default (app: Hono) =>
shortcode,
url: getUrl(url, config),
visibleInPicker: true,
ownerId: global ? null : user.id,
category,
contentType,
alt,
})