mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Add new admin emoji API
This commit is contained in:
parent
b979daa39a
commit
8fedd1a07d
|
|
@ -1,10 +1,10 @@
|
||||||
import { Args, type Command, Flags, type Interfaces } from "@oclif/core";
|
import { Args, type Command, Flags, type Interfaces } from "@oclif/core";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { and, eq, getTableColumns, like } from "drizzle-orm";
|
import { and, eq, getTableColumns, like } from "drizzle-orm";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
import { Emojis, Instances, Users } from "~drizzle/schema";
|
import { Emojis, Instances, Users } from "~drizzle/schema";
|
||||||
import { User } from "~packages/database-interface/user";
|
import { User } from "~packages/database-interface/user";
|
||||||
import { BaseCommand } from "./base";
|
import { BaseCommand } from "./base";
|
||||||
import { db } from "~drizzle/db";
|
|
||||||
|
|
||||||
export type FlagsType<T extends typeof Command> = Interfaces.InferredFlags<
|
export type FlagsType<T extends typeof Command> = Interfaces.InferredFlags<
|
||||||
(typeof BaseCommand)["baseFlags"] & T["flags"]
|
(typeof BaseCommand)["baseFlags"] & T["flags"]
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
|
import confirm from "@inquirer/confirm";
|
||||||
import { Args, Flags } from "@oclif/core";
|
import { Args, Flags } from "@oclif/core";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||||
|
import ora from "ora";
|
||||||
import { EmojiFinderCommand } from "~cli/classes";
|
import { EmojiFinderCommand } from "~cli/classes";
|
||||||
import { formatArray } from "~cli/utils/format";
|
import { formatArray } from "~cli/utils/format";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Emojis } from "~drizzle/schema";
|
import { Emojis } from "~drizzle/schema";
|
||||||
import confirm from "@inquirer/confirm";
|
|
||||||
import ora from "ora";
|
|
||||||
|
|
||||||
export default class EmojiDelete extends EmojiFinderCommand<
|
export default class EmojiDelete extends EmojiFinderCommand<
|
||||||
typeof EmojiDelete
|
typeof EmojiDelete
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { Args, Flags } from "@oclif/core";
|
import { Args, Flags } from "@oclif/core";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { and, inArray, isNull } from "drizzle-orm";
|
||||||
|
import { lookup } from "mime-types";
|
||||||
import ora from "ora";
|
import ora from "ora";
|
||||||
|
import { unzip } from "unzipit";
|
||||||
import { BaseCommand } from "~/cli/base";
|
import { BaseCommand } from "~/cli/base";
|
||||||
import { getUrl } from "~database/entities/Attachment";
|
import { getUrl } from "~database/entities/Attachment";
|
||||||
import { db } from "~drizzle/db";
|
import { db } from "~drizzle/db";
|
||||||
import { Emojis } from "~drizzle/schema";
|
import { Emojis } from "~drizzle/schema";
|
||||||
import { config } from "~packages/config-manager";
|
import { config } from "~packages/config-manager";
|
||||||
import { MediaBackend } from "~packages/media-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 = {
|
type MetaType = {
|
||||||
emojis: {
|
emojis: {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { execute } from "@oclif/core";
|
import { execute } from "@oclif/core";
|
||||||
import EmojiAdd from "./commands/emoji/add";
|
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 UserCreate from "./commands/user/create";
|
||||||
import UserDelete from "./commands/user/delete";
|
import UserDelete from "./commands/user/delete";
|
||||||
import UserList from "./commands/user/list";
|
import UserList from "./commands/user/list";
|
||||||
import UserReset from "./commands/user/reset";
|
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
|
// Use "explicit" oclif strategy to avoid issues with oclif's module resolver and bundling
|
||||||
export const commands = {
|
export const commands = {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { emojiValidator } from "@api";
|
||||||
import { proxyUrl } from "@response";
|
import { proxyUrl } from "@response";
|
||||||
import { type InferSelectModel, and, eq } from "drizzle-orm";
|
import { type InferSelectModel, and, eq } from "drizzle-orm";
|
||||||
import type * as Lysand from "lysand-types";
|
import type * as Lysand from "lysand-types";
|
||||||
|
|
@ -16,8 +17,7 @@ export type EmojiWithInstance = InferSelectModel<typeof Emojis> & {
|
||||||
* @returns An array of emojis
|
* @returns An array of emojis
|
||||||
*/
|
*/
|
||||||
export const parseEmojis = async (text: string) => {
|
export const parseEmojis = async (text: string) => {
|
||||||
const regex = /:[a-zA-Z0-9_]+:/g;
|
const matches = text.match(emojiValidator);
|
||||||
const matches = text.match(regex);
|
|
||||||
if (!matches) return [];
|
if (!matches) return [];
|
||||||
const emojis = await db.query.Emojis.findMany({
|
const emojis = await db.query.Emojis.findMany({
|
||||||
where: (emoji, { eq, or }) =>
|
where: (emoji, { eq, or }) =>
|
||||||
|
|
@ -93,6 +93,8 @@ export const fetchEmoji = async (
|
||||||
*/
|
*/
|
||||||
export const emojiToAPI = (emoji: EmojiWithInstance): APIEmoji => {
|
export const emojiToAPI = (emoji: EmojiWithInstance): APIEmoji => {
|
||||||
return {
|
return {
|
||||||
|
// @ts-expect-error ID is not in regular Mastodon API
|
||||||
|
id: emoji.id,
|
||||||
shortcode: emoji.shortcode,
|
shortcode: emoji.shortcode,
|
||||||
static_url: proxyUrl(emoji.url) ?? "", // TODO: Add static version
|
static_url: proxyUrl(emoji.url) ?? "", // TODO: Add static version
|
||||||
url: proxyUrl(emoji.url) ?? "",
|
url: proxyUrl(emoji.url) ?? "",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
|
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
|
||||||
import { dualLogger } from "@loggers";
|
import { dualLogger } from "@loggers";
|
||||||
import { sanitizeHtml } from "@sanitization";
|
import { sanitizeHtml, sanitizeHtmlInline } from "@sanitization";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import {
|
import {
|
||||||
type InferSelectModel,
|
type InferSelectModel,
|
||||||
|
|
@ -498,18 +498,20 @@ export const replaceTextMentions = async (text: string, mentions: User[]) => {
|
||||||
export const contentToHtml = async (
|
export const contentToHtml = async (
|
||||||
content: Lysand.ContentFormat,
|
content: Lysand.ContentFormat,
|
||||||
mentions: User[] = [],
|
mentions: User[] = [],
|
||||||
|
inline = false,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
let htmlContent: string;
|
let htmlContent: string;
|
||||||
|
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
|
||||||
|
|
||||||
if (content["text/html"]) {
|
if (content["text/html"]) {
|
||||||
htmlContent = await sanitizeHtml(content["text/html"].content);
|
htmlContent = await sanitizer(content["text/html"].content);
|
||||||
} else if (content["text/markdown"]) {
|
} else if (content["text/markdown"]) {
|
||||||
htmlContent = await sanitizeHtml(
|
htmlContent = await sanitizer(
|
||||||
await markdownParse(content["text/markdown"].content),
|
await markdownParse(content["text/markdown"].content),
|
||||||
);
|
);
|
||||||
} else if (content["text/plain"]?.content) {
|
} else if (content["text/plain"]?.content) {
|
||||||
// Split by newline and add <p> tags
|
// Split by newline and add <p> tags
|
||||||
htmlContent = (await sanitizeHtml(content["text/plain"].content))
|
htmlContent = (await sanitizer(content["text/plain"].content))
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => `<p>${line}</p>`)
|
.map((line) => `<p>${line}</p>`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
|
||||||
94
docs/api/emojis.md
Normal file
94
docs/api/emojis.md
Normal file
|
|
@ -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.
|
||||||
13
docs/api/index.md
Normal file
13
docs/api/index.md
Normal file
|
|
@ -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).
|
||||||
|
|
@ -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.
|
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.
|
||||||
|
|
||||||
5
index.ts
5
index.ts
|
|
@ -167,7 +167,10 @@ app.all("*", async (context) => {
|
||||||
proxy?.headers.set("Cache-Control", "max-age=31536000");
|
proxy?.headers.set("Cache-Control", "max-age=31536000");
|
||||||
|
|
||||||
if (!proxy || proxy.status === 404) {
|
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;
|
return proxy;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { proxyUrl } from "@response";
|
||||||
import { sanitizedHtmlStrip } from "@sanitization";
|
import { sanitizedHtmlStrip } from "@sanitization";
|
||||||
import {
|
import {
|
||||||
type InferInsertModel,
|
type InferInsertModel,
|
||||||
|
|
@ -442,7 +443,17 @@ export class Note {
|
||||||
(mention) => mention.instanceId === null,
|
(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) {
|
for (const mention of mentionedLocalUsers) {
|
||||||
replacedContent = replacedContent.replace(
|
replacedContent = replacedContent.replace(
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,27 @@ export class User {
|
||||||
return isLocal ? username : `${username}@${baseUrl}`;
|
return isLocal ? username : `${username}@${baseUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(data: Partial<typeof Users.$inferSelect>) {
|
||||||
|
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 {
|
toAPI(isOwnAccount = false): APIAccount {
|
||||||
const user = this.getUser();
|
const user = this.getUser();
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ import {
|
||||||
expect,
|
expect,
|
||||||
test,
|
test,
|
||||||
} from "bun:test";
|
} from "bun:test";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "~drizzle/db";
|
||||||
|
import { Users } from "~drizzle/schema";
|
||||||
import {
|
import {
|
||||||
deleteOldTestUsers,
|
deleteOldTestUsers,
|
||||||
getTestStatuses,
|
getTestStatuses,
|
||||||
|
|
@ -15,10 +19,6 @@ import {
|
||||||
} from "~tests/utils";
|
} from "~tests/utils";
|
||||||
import type { Account as APIAccount } from "~types/mastodon/account";
|
import type { Account as APIAccount } from "~types/mastodon/account";
|
||||||
import { meta } from "./index";
|
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 username = randomBytes(10).toString("hex");
|
||||||
const username2 = 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
46
utils/api.ts
46
utils/api.ts
|
|
@ -11,6 +11,8 @@ import {
|
||||||
createRegExp,
|
createRegExp,
|
||||||
digit,
|
digit,
|
||||||
exactly,
|
exactly,
|
||||||
|
letter,
|
||||||
|
oneOrMore,
|
||||||
} from "magic-regexp";
|
} from "magic-regexp";
|
||||||
import { parse } from "qs";
|
import { parse } from "qs";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
@ -49,6 +51,12 @@ export const idValidator = createRegExp(
|
||||||
[caseInsensitive],
|
[caseInsensitive],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const emojiValidator = createRegExp(
|
||||||
|
// A-Z a-z 0-9 _ -
|
||||||
|
oneOrMore(letter.or(digit).or(exactly("_")).or(exactly("-"))),
|
||||||
|
[caseInsensitive],
|
||||||
|
);
|
||||||
|
|
||||||
export const handleZodError = (
|
export const handleZodError = (
|
||||||
result:
|
result:
|
||||||
| { success: true; data?: object }
|
| { success: true; data?: object }
|
||||||
|
|
@ -209,16 +217,48 @@ export const jsonOrForm = () => {
|
||||||
context.req.formData = () => Promise.resolve(parsed);
|
context.req.formData = () => Promise.resolve(parsed);
|
||||||
// @ts-ignore I'm so sorry for this
|
// @ts-ignore I'm so sorry for this
|
||||||
context.req.bodyCache.formData = parsed;
|
context.req.bodyCache.formData = parsed;
|
||||||
|
} 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<string, File>();
|
||||||
|
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 {
|
} else {
|
||||||
const parsed = parse(await context.req.text(), {
|
if (!files.has(key)) {
|
||||||
|
files.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parse(urlparams.toString(), {
|
||||||
parseArrays: true,
|
parseArrays: true,
|
||||||
interpretNumericEntities: true,
|
interpretNumericEntities: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// @ts-ignore Very bad hack
|
// @ts-ignore Very bad hack
|
||||||
context.req.formData = () => Promise.resolve(parsed);
|
context.req.parseBody = <T extends BodyData = BodyData>() =>
|
||||||
|
Promise.resolve({
|
||||||
|
...parsed,
|
||||||
|
...Object.fromEntries(files),
|
||||||
|
} as T);
|
||||||
|
|
||||||
|
context.req.formData = () =>
|
||||||
// @ts-ignore I'm so sorry for this
|
// @ts-ignore I'm so sorry for this
|
||||||
context.req.bodyCache.formData = parsed;
|
Promise.resolve({
|
||||||
|
...parsed,
|
||||||
|
...Object.fromEntries(files),
|
||||||
|
});
|
||||||
|
// @ts-ignore I'm so sorry for this
|
||||||
|
context.req.bodyCache.formData = {
|
||||||
|
...parsed,
|
||||||
|
...Object.fromEntries(files),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue