Merge pull request #1 from thedudedies21/finish_base_webhooks
Finish base webhooks
This commit is contained in:
commit
390611b6d2
@ -4620,7 +4620,9 @@
|
||||
"channel": {
|
||||
"$ref": "#/components/schemas/RateLimitOptions"
|
||||
},
|
||||
"auth": {}
|
||||
"auth": {
|
||||
"$ref": "#/components/schemas/AuthRateLimit"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"auth",
|
||||
@ -4629,6 +4631,21 @@
|
||||
"webhook"
|
||||
]
|
||||
},
|
||||
"AuthRateLimit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"login": {
|
||||
"$ref": "#/components/schemas/RateLimitOptions"
|
||||
},
|
||||
"register": {
|
||||
"$ref": "#/components/schemas/RateLimitOptions"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"login",
|
||||
"register"
|
||||
]
|
||||
},
|
||||
"GlobalRateLimits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -5857,6 +5874,15 @@
|
||||
},
|
||||
"mention_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"flags": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_viewed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -8872,18 +8898,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"enforce_nonce": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"nonce": {
|
||||
"type": "string"
|
||||
},
|
||||
"poll": {
|
||||
"$ref": "#/definitions/Poll"
|
||||
},
|
||||
"sticker_ids": {
|
||||
"type": "string"
|
||||
},
|
||||
"message_reference": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -8895,12 +8909,44 @@
|
||||
},
|
||||
"guild_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"fail_if_not_exists": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"message_id"
|
||||
]
|
||||
},
|
||||
"sticker_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"nonce": {
|
||||
"type": "string"
|
||||
},
|
||||
"enforce_nonce": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"poll": {
|
||||
"$ref": "#/components/schemas/PollCreationSchema"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebhookUpdateSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"channel_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -9144,6 +9190,104 @@
|
||||
"tags": [
|
||||
"webhooks"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No description available"
|
||||
},
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No description available"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "webhook_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "webhook_id"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"webhooks"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WebhookUpdateSchema"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WebhookCreateResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "No description available"
|
||||
},
|
||||
"404": {
|
||||
"description": "No description available"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "webhook_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "webhook_id"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"webhooks"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/webhooks/{webhook_id}/{token}/": {
|
||||
@ -9268,6 +9412,122 @@
|
||||
"tags": [
|
||||
"webhooks"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No description available"
|
||||
},
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No description available"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "webhook_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "webhook_id"
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "token"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"webhooks"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WebhookUpdateSchema"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "No description available"
|
||||
},
|
||||
"404": {
|
||||
"description": "No description available"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "webhook_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "webhook_id"
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "token"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"webhooks"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/voice/regions/": {
|
||||
|
4979
assets/schemas.json
Normal file → Executable file
4979
assets/schemas.json
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
@ -32,7 +32,7 @@ export const NO_AUTHORIZATION_ROUTES = [
|
||||
"POST /auth/reset",
|
||||
"GET /invites/",
|
||||
// Routes with a seperate auth system
|
||||
/^(POST|HEAD) \/webhooks\/\d+\/\w+\/?/, // no token requires auth
|
||||
/^(POST|HEAD|GET|PATCH|DELETE) \/webhooks\/\d+\/\w+\/?/, // no token requires auth
|
||||
// Public information endpoints
|
||||
"GET /ping",
|
||||
"GET /gateway",
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
handleFile,
|
||||
isTextChannel,
|
||||
trimSpecial,
|
||||
FieldErrors,
|
||||
} from "@spacebar/util";
|
||||
import crypto from "crypto";
|
||||
import { Request, Response, Router } from "express";
|
||||
@ -111,8 +112,39 @@ router.post(
|
||||
name = trimSpecial(name);
|
||||
|
||||
// TODO: move this
|
||||
if (name === "clyde") throw new HTTPError("Invalid name", 400);
|
||||
if (name === "Spacebar Ghost") throw new HTTPError("Invalid name", 400);
|
||||
if (name) {
|
||||
const check_username = name.replace(/\s/g, "");
|
||||
if (!check_username) {
|
||||
throw FieldErrors({
|
||||
username: {
|
||||
code: "BASE_TYPE_REQUIRED",
|
||||
message: req.t("common:field.BASE_TYPE_REQUIRED"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { maxUsername } = Config.get().limits.user;
|
||||
if (
|
||||
check_username.length > maxUsername ||
|
||||
check_username.length < 2
|
||||
) {
|
||||
throw FieldErrors({
|
||||
username: {
|
||||
code: "BASE_TYPE_BAD_LENGTH",
|
||||
message: `Must be between 2 and ${maxUsername} in length.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const blockedContains = ["discord", "clyde", "spacebar"];
|
||||
for (const word of blockedContains) {
|
||||
if (name.toLowerCase().includes(word)) {
|
||||
return res.status(400).json({
|
||||
username: [`Username cannot contain "${word}"`],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar);
|
||||
|
||||
|
@ -10,6 +10,9 @@ import {
|
||||
WebhookExecuteSchema,
|
||||
emitEvent,
|
||||
uploadFile,
|
||||
WebhooksUpdateEvent,
|
||||
WebhookUpdateSchema,
|
||||
handleFile,
|
||||
} from "@spacebar/util";
|
||||
import { Request, Response, Router } from "express";
|
||||
import { HTTPError } from "lambert-server";
|
||||
@ -129,13 +132,38 @@ router.post(
|
||||
|
||||
// block username from containing certain words
|
||||
// TODO: configurable additions
|
||||
const blockedContains = ["discord", "clyde", "spacebar"];
|
||||
for (const word of blockedContains) {
|
||||
if (body.username?.toLowerCase().includes(word)) {
|
||||
return res.status(400).json({
|
||||
username: [`Username cannot contain "${word}"`],
|
||||
if (body.username) {
|
||||
const check_username = body.username.replace(/\s/g, "");
|
||||
if (!check_username) {
|
||||
throw FieldErrors({
|
||||
username: {
|
||||
code: "BASE_TYPE_REQUIRED",
|
||||
message: req.t("common:field.BASE_TYPE_REQUIRED"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { maxUsername } = Config.get().limits.user;
|
||||
if (
|
||||
check_username.length > maxUsername ||
|
||||
check_username.length < 2
|
||||
) {
|
||||
throw FieldErrors({
|
||||
username: {
|
||||
code: "BASE_TYPE_BAD_LENGTH",
|
||||
message: `Must be between 2 and ${maxUsername} in length.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const blockedContains = ["discord", "clyde", "spacebar"];
|
||||
for (const word of blockedContains) {
|
||||
if (body.username.toLowerCase().includes(word)) {
|
||||
return res.status(400).json({
|
||||
username: [`Username cannot contain "${word}"`],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// block username from being certain words
|
||||
@ -248,4 +276,105 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/",
|
||||
route({
|
||||
responses: {
|
||||
204: {},
|
||||
400: {
|
||||
body: "APIErrorResponse",
|
||||
},
|
||||
404: {},
|
||||
},
|
||||
}),
|
||||
async (req: Request, res: Response) => {
|
||||
const { webhook_id, token } = req.params;
|
||||
|
||||
const webhook = await Webhook.findOne({
|
||||
where: {
|
||||
id: webhook_id,
|
||||
},
|
||||
relations: ["channel", "guild", "application"],
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
|
||||
}
|
||||
|
||||
if (webhook.token !== token) {
|
||||
throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED;
|
||||
}
|
||||
const channel_id = webhook.channel_id;
|
||||
await Webhook.delete({ id: webhook_id });
|
||||
|
||||
await emitEvent({
|
||||
event: "WEBHOOKS_UPDATE",
|
||||
channel_id,
|
||||
data: {
|
||||
channel_id,
|
||||
guild_id: webhook.guild_id,
|
||||
},
|
||||
} as WebhooksUpdateEvent);
|
||||
|
||||
res.sendStatus(204);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/",
|
||||
route({
|
||||
requestBody: "WebhookUpdateSchema",
|
||||
responses: {
|
||||
200: {
|
||||
body: "Message",
|
||||
},
|
||||
400: {
|
||||
body: "APIErrorResponse",
|
||||
},
|
||||
403: {},
|
||||
404: {},
|
||||
},
|
||||
}),
|
||||
async (req: Request, res: Response) => {
|
||||
const { webhook_id, token } = req.params;
|
||||
const body = req.body as WebhookUpdateSchema;
|
||||
|
||||
const webhook = await Webhook.findOneOrFail({
|
||||
where: { id: webhook_id },
|
||||
relations: [
|
||||
"user",
|
||||
"channel",
|
||||
"source_channel",
|
||||
"guild",
|
||||
"source_guild",
|
||||
"application",
|
||||
],
|
||||
});
|
||||
|
||||
const channel_id = webhook.channel_id;
|
||||
if (!body.name && !body.avatar) {
|
||||
throw new HTTPError("Empty messages are not allowed", 50006);
|
||||
}
|
||||
if (body.avatar)
|
||||
body.avatar = await handleFile(
|
||||
`/avatars/${webhook_id}`,
|
||||
body.avatar as string,
|
||||
);
|
||||
webhook.assign(body);
|
||||
|
||||
await Promise.all([
|
||||
webhook.save(),
|
||||
emitEvent({
|
||||
event: "WEBHOOKS_UPDATE",
|
||||
channel_id,
|
||||
data: {
|
||||
channel_id,
|
||||
guild_id: webhook.guild_id,
|
||||
},
|
||||
} as WebhooksUpdateEvent),
|
||||
]);
|
||||
res.status(204);
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
@ -4,8 +4,15 @@ import {
|
||||
DiscordApiErrors,
|
||||
getPermission,
|
||||
Webhook,
|
||||
WebhooksUpdateEvent,
|
||||
emitEvent,
|
||||
WebhookUpdateSchema,
|
||||
Channel,
|
||||
handleFile,
|
||||
FieldErrors,
|
||||
} from "@spacebar/util";
|
||||
import { Request, Response, Router } from "express";
|
||||
import { HTTPError } from "lambert-server";
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
@ -54,4 +61,169 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/",
|
||||
route({
|
||||
responses: {
|
||||
204: {},
|
||||
400: {
|
||||
body: "APIErrorResponse",
|
||||
},
|
||||
404: {},
|
||||
},
|
||||
}),
|
||||
async (req: Request, res: Response) => {
|
||||
const { webhook_id } = req.params;
|
||||
|
||||
const webhook = await Webhook.findOneOrFail({
|
||||
where: { id: webhook_id },
|
||||
relations: [
|
||||
"user",
|
||||
"channel",
|
||||
"source_channel",
|
||||
"guild",
|
||||
"source_guild",
|
||||
"application",
|
||||
],
|
||||
});
|
||||
|
||||
if (webhook.guild_id) {
|
||||
const permission = await getPermission(
|
||||
req.user_id,
|
||||
webhook.guild_id,
|
||||
);
|
||||
|
||||
if (!permission.has("MANAGE_WEBHOOKS"))
|
||||
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
|
||||
} else if (webhook.user_id != req.user_id)
|
||||
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
|
||||
|
||||
const channel_id = webhook.channel_id;
|
||||
await Webhook.delete({ id: webhook_id });
|
||||
|
||||
await emitEvent({
|
||||
event: "WEBHOOKS_UPDATE",
|
||||
channel_id,
|
||||
data: {
|
||||
channel_id,
|
||||
guild_id: webhook.guild_id,
|
||||
},
|
||||
} as WebhooksUpdateEvent);
|
||||
|
||||
res.sendStatus(204);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/",
|
||||
route({
|
||||
requestBody: "WebhookUpdateSchema",
|
||||
responses: {
|
||||
200: {
|
||||
body: "WebhookCreateResponse",
|
||||
},
|
||||
400: {
|
||||
body: "APIErrorResponse",
|
||||
},
|
||||
403: {},
|
||||
404: {},
|
||||
},
|
||||
}),
|
||||
async (req: Request, res: Response) => {
|
||||
const { webhook_id } = req.params;
|
||||
const body = req.body as WebhookUpdateSchema;
|
||||
|
||||
const webhook = await Webhook.findOneOrFail({
|
||||
where: { id: webhook_id },
|
||||
relations: [
|
||||
"user",
|
||||
"channel",
|
||||
"source_channel",
|
||||
"guild",
|
||||
"source_guild",
|
||||
"application",
|
||||
],
|
||||
});
|
||||
|
||||
if (webhook.guild_id) {
|
||||
const permission = await getPermission(
|
||||
req.user_id,
|
||||
webhook.guild_id,
|
||||
);
|
||||
|
||||
if (!permission.has("MANAGE_WEBHOOKS"))
|
||||
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
|
||||
} else if (webhook.user_id != req.user_id)
|
||||
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
|
||||
|
||||
if (!body.name && !body.avatar && !body.channel_id) {
|
||||
throw new HTTPError("Empty messages are not allowed", 50006);
|
||||
}
|
||||
|
||||
if (body.avatar)
|
||||
body.avatar = await handleFile(
|
||||
`/avatars/${webhook_id}`,
|
||||
body.avatar as string,
|
||||
);
|
||||
|
||||
if (body.name) {
|
||||
const check_username = body.name.replace(/\s/g, "");
|
||||
if (!check_username) {
|
||||
throw FieldErrors({
|
||||
username: {
|
||||
code: "BASE_TYPE_REQUIRED",
|
||||
message: req.t("common:field.BASE_TYPE_REQUIRED"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { maxUsername } = Config.get().limits.user;
|
||||
if (
|
||||
check_username.length > maxUsername ||
|
||||
check_username.length < 2
|
||||
) {
|
||||
throw FieldErrors({
|
||||
username: {
|
||||
code: "BASE_TYPE_BAD_LENGTH",
|
||||
message: `Must be between 2 and ${maxUsername} in length.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const blockedContains = ["discord", "clyde", "spacebar"];
|
||||
for (const word of blockedContains) {
|
||||
if (body.name.toLowerCase().includes(word)) {
|
||||
return res.status(400).json({
|
||||
username: [`Username cannot contain "${word}"`],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const channel_id = body.channel_id || webhook.channel_id;
|
||||
webhook.assign(body);
|
||||
|
||||
if (body.channel_id)
|
||||
webhook.assign({
|
||||
channel: await Channel.findOneOrFail({
|
||||
where: { id: channel_id },
|
||||
}),
|
||||
});
|
||||
console.log(webhook.channel_id);
|
||||
|
||||
await webhook.save();
|
||||
await emitEvent({
|
||||
event: "WEBHOOKS_UPDATE",
|
||||
channel_id,
|
||||
data: {
|
||||
channel_id,
|
||||
guild_id: webhook.guild_id,
|
||||
},
|
||||
} as WebhooksUpdateEvent);
|
||||
|
||||
console.log(webhook.channel_id);
|
||||
res.json(webhook);
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
23
src/util/schemas/WebhookUpdateSchema.ts
Executable file
23
src/util/schemas/WebhookUpdateSchema.ts
Executable file
@ -0,0 +1,23 @@
|
||||
/*
|
||||
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
|
||||
Copyright (C) 2023 Spacebar and Spacebar Contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface WebhookUpdateSchema {
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
channel_id?: string;
|
||||
}
|
@ -86,4 +86,5 @@ export * from "./VoiceVideoSchema";
|
||||
export * from "./WebAuthnSchema";
|
||||
export * from "./WebhookCreateSchema";
|
||||
export * from "./WebhookExecuteSchema";
|
||||
export * from "./WebhookUpdateSchema";
|
||||
export * from "./WidgetModifySchema";
|
||||
|
Loading…
x
Reference in New Issue
Block a user