From 8fedd1a07db9df7031ebbf1161117e89e454b1ec Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sat, 11 May 2024 15:27:28 -1000 Subject: [PATCH] feat(api): :sparkles: Add new admin emoji API --- cli/classes.ts | 2 +- cli/commands/emoji/delete.ts | 4 +- cli/commands/emoji/import.ts | 6 +- cli/index.ts | 6 +- database/entities/Emoji.ts | 6 +- database/entities/Status.ts | 10 +- docs/api/emojis.md | 94 +++++++ docs/api/index.md | 13 + API.md => docs/api/moderation.md | 5 +- index.ts | 5 +- package.json | 282 ++++++++++----------- packages/database-interface/note.ts | 13 +- packages/database-interface/user.ts | 21 ++ server/api/api/v1/accounts/index.test.ts | 8 +- server/api/api/v1/emojis/:id/index.test.ts | 168 ++++++++++++ server/api/api/v1/emojis/:id/index.ts | 177 +++++++++++++ server/api/api/v1/emojis/index.test.ts | 116 +++++++++ server/api/api/v1/emojis/index.ts | 125 +++++++++ utils/api.ts | 48 +++- utils/content_types.ts | 12 + 20 files changed, 954 insertions(+), 167 deletions(-) create mode 100644 docs/api/emojis.md create mode 100644 docs/api/index.md rename API.md => docs/api/moderation.md (99%) create mode 100644 server/api/api/v1/emojis/:id/index.test.ts create mode 100644 server/api/api/v1/emojis/:id/index.ts create mode 100644 server/api/api/v1/emojis/index.test.ts create mode 100644 server/api/api/v1/emojis/index.ts diff --git a/cli/classes.ts b/cli/classes.ts index a2f32019..55f40ae8 100644 --- a/cli/classes.ts +++ b/cli/classes.ts @@ -1,10 +1,10 @@ import { Args, type Command, Flags, type Interfaces } from "@oclif/core"; import chalk from "chalk"; import { and, eq, getTableColumns, like } from "drizzle-orm"; +import { db } from "~drizzle/db"; import { Emojis, Instances, Users } from "~drizzle/schema"; import { User } from "~packages/database-interface/user"; import { BaseCommand } from "./base"; -import { db } from "~drizzle/db"; export type FlagsType = Interfaces.InferredFlags< (typeof BaseCommand)["baseFlags"] & T["flags"] diff --git a/cli/commands/emoji/delete.ts b/cli/commands/emoji/delete.ts index 584467a6..b0d444cf 100644 --- a/cli/commands/emoji/delete.ts +++ b/cli/commands/emoji/delete.ts @@ -1,12 +1,12 @@ +import confirm from "@inquirer/confirm"; import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { and, eq, inArray, isNull } from "drizzle-orm"; +import ora from "ora"; import { EmojiFinderCommand } from "~cli/classes"; import { formatArray } from "~cli/utils/format"; import { db } from "~drizzle/db"; import { Emojis } from "~drizzle/schema"; -import confirm from "@inquirer/confirm"; -import ora from "ora"; export default class EmojiDelete extends EmojiFinderCommand< typeof EmojiDelete diff --git a/cli/commands/emoji/import.ts b/cli/commands/emoji/import.ts index e59d2782..28360eaf 100644 --- a/cli/commands/emoji/import.ts +++ b/cli/commands/emoji/import.ts @@ -1,15 +1,15 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; +import { and, inArray, isNull } from "drizzle-orm"; +import { lookup } from "mime-types"; import ora from "ora"; +import { unzip } from "unzipit"; import { BaseCommand } from "~/cli/base"; import { getUrl } from "~database/entities/Attachment"; import { db } from "~drizzle/db"; import { Emojis } from "~drizzle/schema"; import { config } from "~packages/config-manager"; import { MediaBackend } from "~packages/media-manager"; -import { unzip } from "unzipit"; -import { and, inArray, isNull } from "drizzle-orm"; -import { lookup } from "mime-types"; type MetaType = { emojis: { diff --git a/cli/index.ts b/cli/index.ts index 2dd3744a..17cd5a78 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1,12 +1,12 @@ import { execute } from "@oclif/core"; import EmojiAdd from "./commands/emoji/add"; +import EmojiDelete from "./commands/emoji/delete"; +import EmojiImport from "./commands/emoji/import"; +import EmojiList from "./commands/emoji/list"; import UserCreate from "./commands/user/create"; import UserDelete from "./commands/user/delete"; import UserList from "./commands/user/list"; import UserReset from "./commands/user/reset"; -import EmojiDelete from "./commands/emoji/delete"; -import EmojiList from "./commands/emoji/list"; -import EmojiImport from "./commands/emoji/import"; // Use "explicit" oclif strategy to avoid issues with oclif's module resolver and bundling export const commands = { diff --git a/database/entities/Emoji.ts b/database/entities/Emoji.ts index 796ba527..6ccee689 100644 --- a/database/entities/Emoji.ts +++ b/database/entities/Emoji.ts @@ -1,3 +1,4 @@ +import { emojiValidator } from "@api"; import { proxyUrl } from "@response"; import { type InferSelectModel, and, eq } from "drizzle-orm"; import type * as Lysand from "lysand-types"; @@ -16,8 +17,7 @@ export type EmojiWithInstance = InferSelectModel & { * @returns An array of emojis */ export const parseEmojis = async (text: string) => { - const regex = /:[a-zA-Z0-9_]+:/g; - const matches = text.match(regex); + const matches = text.match(emojiValidator); if (!matches) return []; const emojis = await db.query.Emojis.findMany({ where: (emoji, { eq, or }) => @@ -93,6 +93,8 @@ export const fetchEmoji = async ( */ export const emojiToAPI = (emoji: EmojiWithInstance): APIEmoji => { return { + // @ts-expect-error ID is not in regular Mastodon API + id: emoji.id, shortcode: emoji.shortcode, static_url: proxyUrl(emoji.url) ?? "", // TODO: Add static version url: proxyUrl(emoji.url) ?? "", diff --git a/database/entities/Status.ts b/database/entities/Status.ts index b1b9820a..4347a453 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -1,6 +1,6 @@ import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; import { dualLogger } from "@loggers"; -import { sanitizeHtml } from "@sanitization"; +import { sanitizeHtml, sanitizeHtmlInline } from "@sanitization"; import { config } from "config-manager"; import { type InferSelectModel, @@ -498,18 +498,20 @@ export const replaceTextMentions = async (text: string, mentions: User[]) => { export const contentToHtml = async ( content: Lysand.ContentFormat, mentions: User[] = [], + inline = false, ): Promise => { let htmlContent: string; + const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml; if (content["text/html"]) { - htmlContent = await sanitizeHtml(content["text/html"].content); + htmlContent = await sanitizer(content["text/html"].content); } else if (content["text/markdown"]) { - htmlContent = await sanitizeHtml( + htmlContent = await sanitizer( await markdownParse(content["text/markdown"].content), ); } else if (content["text/plain"]?.content) { // Split by newline and add

tags - htmlContent = (await sanitizeHtml(content["text/plain"].content)) + htmlContent = (await sanitizer(content["text/plain"].content)) .split("\n") .map((line) => `

${line}

`) .join("\n"); diff --git a/docs/api/emojis.md b/docs/api/emojis.md new file mode 100644 index 00000000..41ecf2fd --- /dev/null +++ b/docs/api/emojis.md @@ -0,0 +1,94 @@ +# Emoji API + +An Emoji API is made available to administrators to manage custom emoji on the instance. We recommend using Lysand's CLI to manage emoji, but this API is available for those who prefer to use it. + +## Create Emoji + +```http +POST /api/v1/emojis +``` + +Creates a new custom emoji on the instance. + +### Parameters + +- `Content-Type`: `multipart/form-data`, `application/json` or `application/x-www-form-urlencoded`. If uploading a file, use `multipart/form-data`. + +- `shortcode`: string, required. The shortcode for the emoji. Must be 2-64 characters long and contain only alphanumeric characters, dashes, and underscores. +- `element`: string or file, required. The image file for the emoji. This can be a URL or a file upload. +- `alt`: string, optional. The alt text for the emoji. Defaults to the shortcode. + +### Response + +```ts +// 200 OK +{ + id: string, + shortcode: string, + url: string, + static_url: string, + visible_in_picker: boolean, + // Lysand does not have a category system for emoji yet, so this is always undefined. + category: undefined, +} +``` + +## Get Emoji + +```http +GET /api/v1/emojis/:id +``` + +Retrieves information about a custom emoji on the instance. + +### Response + +```ts +// 200 OK +{ + id: string, + shortcode: string, + url: string, + static_url: string, + visible_in_picker: boolean, + category: undefined, +} +``` + +## Edit Emoji + +```http +PATCH /api/v1/emojis/:id +``` + +Edits a custom emoji on the instance. + +### Parameters + +- `Content-Type`: `application/json`, `multipart/form-data` or `application/x-www-form-urlencoded`. If uploading a file, use `multipart/form-data`. + +- `shortcode`: string, optional. The new shortcode for the emoji. Must be 2-64 characters long and contain only alphanumeric characters, dashes, and underscores. +- `element`: string or file, optional. The new image file for the emoji. This can be a URL or a file upload. +- `alt`: string, optional. The new alt text for the emoji. Defaults to the shortcode. + +### Response + +```ts +// 200 OK +{ + id: string, + shortcode: string, + url: string, + static_url: string, + visible_in_picker: boolean, + category: undefined, +} +``` + +## Delete Emoji + +```http +DELETE /api/v1/emojis/:id +``` + +Deletes a custom emoji on the instance. \ No newline at end of file diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 00000000..3b641f54 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,13 @@ +# Lysand API Documentation + +The Lysand API strictly follows the latest available Mastodon API version (Glitch-Soc version). This means that the Lysand API is a superset of the Mastodon API, with additional endpoints and features. + +Some more information about the Mastodon API can be found in the [Mastodon API documentation](https://docs.joinmastodon.org/api/). + +## Emoji API + +For administrators. Please read [the documentation](./emojis.md). + +## Moderation API + +For administrators. Not implemented. Please read [the documentation](./moderation.md). \ No newline at end of file diff --git a/API.md b/docs/api/moderation.md similarity index 99% rename from API.md rename to docs/api/moderation.md index 7d44dec9..7b8edebb 100644 --- a/API.md +++ b/docs/api/moderation.md @@ -1,4 +1,7 @@ -# API +# Moderation API + +> [!WARNING] +> **NOT IMPLEMENTED** The Lysand project uses the Mastodon API to interact with clients. However, the moderation API is custom-made for Lysand Server, as it allows for more fine-grained control over the server's behavior. diff --git a/index.ts b/index.ts index 16105825..610477f3 100644 --- a/index.ts +++ b/index.ts @@ -167,7 +167,10 @@ app.all("*", async (context) => { proxy?.headers.set("Cache-Control", "max-age=31536000"); if (!proxy || proxy.status === 404) { - return errorResponse("Route not found on proxy or API route", 404); + return errorResponse( + "Route not found on proxy or API route. Are you using the correct HTTP method?", + 404, + ); } return proxy; diff --git a/package.json b/package.json index 8ab89d31..80946857 100644 --- a/package.json +++ b/package.json @@ -1,144 +1,144 @@ { - "name": "lysand", - "module": "index.ts", - "type": "module", - "version": "0.5.0", - "description": "A project to build a federated social network", - "author": { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" - }, - "bugs": { - "url": "https://github.com/lysand-org/lysand/issues" - }, - "icon": "https://github.com/lysand-org/lysand", - "license": "AGPL-3.0-or-later", - "keywords": ["federated", "activitypub", "bun"], - "workspaces": ["packages/*"], - "maintainers": [ - { - "email": "contact@cpluspatch.com", - "name": "CPlusPatch", - "url": "https://cpluspatch.com" + "name": "lysand", + "module": "index.ts", + "type": "module", + "version": "0.5.0", + "description": "A project to build a federated social network", + "author": { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + }, + "bugs": { + "url": "https://github.com/lysand-org/lysand/issues" + }, + "icon": "https://github.com/lysand-org/lysand", + "license": "AGPL-3.0-or-later", + "keywords": ["federated", "activitypub", "bun"], + "workspaces": ["packages/*"], + "maintainers": [ + { + "email": "contact@cpluspatch.com", + "name": "CPlusPatch", + "url": "https://cpluspatch.com" + } + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lysand-org/lysand.git" + }, + "private": true, + "scripts": { + "dev": "bun run --hot index.ts", + "start": "NODE_ENV=production bun run dist/index.js --prod", + "lint": "bunx @biomejs/biome check .", + "build": "bun run build.ts", + "cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs,glitch,glitch-dev --exclude-ext sql,log,pem", + "wc": "find server database *.ts docs packages types utils drizzle tests -type f -print0 | wc -m --files0-from=-", + "cli": "bun run cli/index.ts", + "prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'" + }, + "trustedDependencies": [ + "@biomejs/biome", + "@fortawesome/fontawesome-common-types", + "@fortawesome/free-regular-svg-icons", + "@fortawesome/free-solid-svg-icons", + "es5-ext", + "esbuild", + "json-editor-vue", + "msgpackr-extract", + "nuxt-app", + "sharp", + "vue-demi" + ], + "oclif": { + "bin": "cli", + "dirname": "cli", + "commands": { + "strategy": "explicit", + "target": "./cli/index", + "identifier": "commands" + }, + "additionalHelpFlags": ["-h"], + "additionalVersionFlags": ["-v"], + "plugins": [], + "description": "CLI to interface with the Lysand project", + "topicSeparator": " ", + "topics": { + "user": { + "description": "Manage users" + } + }, + "theme": "./cli/theme.json", + "flexibleTaxonomy": true + }, + "devDependencies": { + "@biomejs/biome": "^1.7.0", + "@types/cli-progress": "^3.11.5", + "@types/cli-table": "^0.3.4", + "@types/html-to-text": "^9.0.4", + "@types/ioredis": "^5.0.0", + "@types/jsonld": "^1.5.13", + "@types/markdown-it-container": "^2.0.10", + "@types/mime-types": "^2.1.4", + "@types/pg": "^8.11.5", + "@types/qs": "^6.9.15", + "bun-types": "latest", + "drizzle-kit": "^0.20.14", + "oclif": "^4.10.4", + "ts-prune": "^0.10.3", + "typescript": "latest" + }, + "peerDependencies": { + "typescript": "^5.3.2" + }, + "dependencies": { + "@hackmd/markdown-it-task-lists": "^2.1.4", + "@hono/zod-validator": "^0.2.1", + "@inquirer/confirm": "^3.1.6", + "@inquirer/input": "^2.1.6", + "@json2csv/plainjs": "^7.0.6", + "@oclif/core": "^3.26.6", + "@tufjs/canonical-json": "^2.0.0", + "blurhash": "^2.0.5", + "bullmq": "^5.7.1", + "chalk": "^5.3.0", + "cli-parser": "workspace:*", + "cli-progress": "^3.12.0", + "cli-table": "^0.3.11", + "config-manager": "workspace:*", + "drizzle-orm": "^0.30.7", + "extract-zip": "^2.0.1", + "hono": "^4.3.2", + "html-to-text": "^9.0.5", + "ioredis": "^5.3.2", + "ip-matching": "^2.1.2", + "iso-639-1": "^3.1.0", + "jose": "^5.2.4", + "linkify-html": "^4.1.3", + "linkify-string": "^4.1.3", + "linkifyjs": "^4.1.3", + "log-manager": "workspace:*", + "magic-regexp": "^0.8.0", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "markdown-it-container": "^4.0.0", + "markdown-it-toc-done-right": "^4.2.0", + "media-manager": "workspace:*", + "meilisearch": "^0.39.0", + "mime-types": "^2.1.35", + "oauth4webapi": "^2.4.0", + "ora": "^8.0.1", + "pg": "^8.11.5", + "qs": "^6.12.1", + "sharp": "^0.33.3", + "string-comparison": "^1.3.0", + "stringify-entities": "^4.0.4", + "table": "^6.8.2", + "unzipit": "^1.4.3", + "uqr": "^0.1.2", + "xss": "^1.0.15", + "zod": "^3.22.4", + "zod-validation-error": "^3.2.0" } - ], - "repository": { - "type": "git", - "url": "git+https://github.com/lysand-org/lysand.git" - }, - "private": true, - "scripts": { - "dev": "bun run --hot index.ts", - "start": "NODE_ENV=production bun run dist/index.js --prod", - "lint": "bunx @biomejs/biome check .", - "build": "bun run build.ts", - "cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs,glitch,glitch-dev --exclude-ext sql,log,pem", - "wc": "find server database *.ts docs packages types utils drizzle tests -type f -print0 | wc -m --files0-from=-", - "cli": "bun run cli/index.ts", - "prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'" - }, - "trustedDependencies": [ - "@biomejs/biome", - "@fortawesome/fontawesome-common-types", - "@fortawesome/free-regular-svg-icons", - "@fortawesome/free-solid-svg-icons", - "es5-ext", - "esbuild", - "json-editor-vue", - "msgpackr-extract", - "nuxt-app", - "sharp", - "vue-demi" - ], - "oclif": { - "bin": "cli", - "dirname": "cli", - "commands": { - "strategy": "explicit", - "target": "./cli/index", - "identifier": "commands" - }, - "additionalHelpFlags": ["-h"], - "additionalVersionFlags": ["-v"], - "plugins": [], - "description": "CLI to interface with the Lysand project", - "topicSeparator": " ", - "topics": { - "user": { - "description": "Manage users" - } - }, - "theme": "./cli/theme.json", - "flexibleTaxonomy": true - }, - "devDependencies": { - "@biomejs/biome": "^1.7.0", - "@types/cli-progress": "^3.11.5", - "@types/cli-table": "^0.3.4", - "@types/html-to-text": "^9.0.4", - "@types/ioredis": "^5.0.0", - "@types/jsonld": "^1.5.13", - "@types/markdown-it-container": "^2.0.10", - "@types/mime-types": "^2.1.4", - "@types/pg": "^8.11.5", - "@types/qs": "^6.9.15", - "bun-types": "latest", - "drizzle-kit": "^0.20.14", - "oclif": "^4.10.4", - "ts-prune": "^0.10.3", - "typescript": "latest" - }, - "peerDependencies": { - "typescript": "^5.3.2" - }, - "dependencies": { - "@hackmd/markdown-it-task-lists": "^2.1.4", - "@hono/zod-validator": "^0.2.1", - "@inquirer/confirm": "^3.1.6", - "@inquirer/input": "^2.1.6", - "@json2csv/plainjs": "^7.0.6", - "@oclif/core": "^3.26.6", - "@tufjs/canonical-json": "^2.0.0", - "blurhash": "^2.0.5", - "bullmq": "^5.7.1", - "chalk": "^5.3.0", - "cli-parser": "workspace:*", - "cli-progress": "^3.12.0", - "cli-table": "^0.3.11", - "config-manager": "workspace:*", - "drizzle-orm": "^0.30.7", - "extract-zip": "^2.0.1", - "hono": "^4.3.2", - "html-to-text": "^9.0.5", - "ioredis": "^5.3.2", - "ip-matching": "^2.1.2", - "iso-639-1": "^3.1.0", - "jose": "^5.2.4", - "linkify-html": "^4.1.3", - "linkify-string": "^4.1.3", - "linkifyjs": "^4.1.3", - "log-manager": "workspace:*", - "magic-regexp": "^0.8.0", - "markdown-it": "^14.1.0", - "markdown-it-anchor": "^8.6.7", - "markdown-it-container": "^4.0.0", - "markdown-it-toc-done-right": "^4.2.0", - "media-manager": "workspace:*", - "meilisearch": "^0.39.0", - "mime-types": "^2.1.35", - "oauth4webapi": "^2.4.0", - "ora": "^8.0.1", - "pg": "^8.11.5", - "qs": "^6.12.1", - "sharp": "^0.33.3", - "string-comparison": "^1.3.0", - "stringify-entities": "^4.0.4", - "table": "^6.8.2", - "unzipit": "^1.4.3", - "uqr": "^0.1.2", - "xss": "^1.0.15", - "zod": "^3.22.4", - "zod-validation-error": "^3.2.0" - } } diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts index c6718a52..d802c94a 100644 --- a/packages/database-interface/note.ts +++ b/packages/database-interface/note.ts @@ -1,3 +1,4 @@ +import { proxyUrl } from "@response"; import { sanitizedHtmlStrip } from "@sanitization"; import { type InferInsertModel, @@ -442,7 +443,17 @@ export class Note { (mention) => mention.instanceId === null, ); - let replacedContent = data.content; + // Rewrite all src tags to go through proxy + let replacedContent = new HTMLRewriter() + .on("[src]", { + element(element) { + element.setAttribute( + "src", + proxyUrl(element.getAttribute("src") ?? "") ?? "", + ); + }, + }) + .transform(data.content); for (const mention of mentionedLocalUsers) { replacedContent = replacedContent.replace( diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index bc4511fd..71ac7e8a 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -407,6 +407,27 @@ export class User { return isLocal ? username : `${username}@${baseUrl}`; } + async update(data: Partial) { + const updated = ( + await db + .update(Users) + .set({ + ...data, + updatedAt: new Date().toISOString(), + }) + .where(eq(Users.id, this.id)) + .returning() + )[0]; + + const newUser = await User.fromId(updated.id); + + if (!newUser) throw new Error("User not found after update"); + + this.user = newUser.getUser(); + + return this; + } + toAPI(isOwnAccount = false): APIAccount { const user = this.getUser(); return { diff --git a/server/api/api/v1/accounts/index.test.ts b/server/api/api/v1/accounts/index.test.ts index a492700a..8687a21a 100644 --- a/server/api/api/v1/accounts/index.test.ts +++ b/server/api/api/v1/accounts/index.test.ts @@ -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"); diff --git a/server/api/api/v1/emojis/:id/index.test.ts b/server/api/api/v1/emojis/:id/index.test.ts new file mode 100644 index 00000000..1ea37580 --- /dev/null +++ b/server/api/api/v1/emojis/:id/index.test.ts @@ -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); + }); +}); diff --git a/server/api/api/v1/emojis/:id/index.ts b/server/api/api/v1/emojis/:id/index.ts new file mode 100644 index 00000000..173f4701 --- /dev/null +++ b/server/api/api/v1/emojis/:id/index.ts @@ -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)); + } + } + }, + ); diff --git a/server/api/api/v1/emojis/index.test.ts b/server/api/api/v1/emojis/index.test.ts new file mode 100644 index 00000000..1b7785ca --- /dev/null +++ b/server/api/api/v1/emojis/index.test.ts @@ -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", + ), + ); + }); +}); diff --git a/server/api/api/v1/emojis/index.ts b/server/api/api/v1/emojis/index.ts new file mode 100644 index 00000000..42a2da16 --- /dev/null +++ b/server/api/api/v1/emojis/index.ts @@ -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, + }), + ); + }, + ); diff --git a/utils/api.ts b/utils/api.ts index ce04c3be..63bf64e2 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -11,6 +11,8 @@ import { createRegExp, digit, exactly, + letter, + oneOrMore, } from "magic-regexp"; import { parse } from "qs"; import type { z } from "zod"; @@ -49,6 +51,12 @@ export const idValidator = createRegExp( [caseInsensitive], ); +export const emojiValidator = createRegExp( + // A-Z a-z 0-9 _ - + oneOrMore(letter.or(digit).or(exactly("_")).or(exactly("-"))), + [caseInsensitive], +); + export const handleZodError = ( result: | { success: true; data?: object } @@ -209,16 +217,48 @@ export const jsonOrForm = () => { context.req.formData = () => Promise.resolve(parsed); // @ts-ignore I'm so sorry for this context.req.bodyCache.formData = parsed; - } else { - const parsed = parse(await context.req.text(), { + } else if (contentType?.includes("multipart/form-data")) { + // Get it as a query format to pass on to qs, then insert back files + const formData = await context.req.formData(); + const urlparams = new URLSearchParams(); + const files = new Map(); + for (const [key, value] of [...formData.entries()]) { + if (Array.isArray(value)) { + for (const val of value) { + urlparams.append(key, val); + } + } else if (!(value instanceof File)) { + urlparams.append(key, String(value)); + } else { + if (!files.has(key)) { + files.set(key, value); + } + } + } + + const parsed = parse(urlparams.toString(), { parseArrays: true, interpretNumericEntities: true, }); // @ts-ignore Very bad hack - context.req.formData = () => Promise.resolve(parsed); + context.req.parseBody = () => + Promise.resolve({ + ...parsed, + ...Object.fromEntries(files), + } as T); + + context.req.formData = () => + // @ts-ignore I'm so sorry for this + Promise.resolve({ + ...parsed, + ...Object.fromEntries(files), + }); // @ts-ignore I'm so sorry for this - context.req.bodyCache.formData = parsed; + context.req.bodyCache.formData = { + ...parsed, + ...Object.fromEntries(files), + }; } await next(); }); diff --git a/utils/content_types.ts b/utils/content_types.ts index 468e2ff3..95a39ac6 100644 --- a/utils/content_types.ts +++ b/utils/content_types.ts @@ -40,3 +40,15 @@ export const urlToContentFormat = ( }, }; }; + +export const mimeLookup = async (url: string) => { + const naiveLookup = lookup(url.replace(new URL(url).search, "")); + + if (naiveLookup) return naiveLookup; + + const fetchLookup = fetch(url, { method: "HEAD" }).then( + (response) => response.headers.get("content-type") || "", + ); + + return fetchLookup; +};