mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 21:51:56 +00:00
feat: added logic for webhook and dynamic secret to use the kms encryption
This commit is contained in:
@@ -269,7 +269,7 @@ export const integrationAuthServiceFactory = ({
|
||||
const awsAssumeIamRoleArnEncrypted = secretManagerEncryptor({
|
||||
plainText: Buffer.from(awsAssumeIamRoleArn)
|
||||
}).cipherTextBlob;
|
||||
updateDoc.encryptedAwsIamAssumRole = awsAssumeIamRoleArnEncrypted;
|
||||
updateDoc.encryptedAwsAssumeIamRoleArn = awsAssumeIamRoleArnEncrypted;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ProjectUpgradeStatus, ProjectVersion, TSecretSnapshotSecretsV2, TSecretVersionsV2 } from "@app/db/schemas";
|
||||
import {
|
||||
ProjectUpgradeStatus,
|
||||
ProjectVersion,
|
||||
SecretKeyEncoding,
|
||||
TSecretSnapshotSecretsV2,
|
||||
TSecretVersionsV2
|
||||
} from "@app/db/schemas";
|
||||
import { TDynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal";
|
||||
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
|
||||
import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
|
||||
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
|
||||
import { TSnapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-v2-dal";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||
import { daysToMillisecond, secondsToMillis } from "@app/lib/dates";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { groupBy, isSamePath, unique } from "@app/lib/fn";
|
||||
@@ -60,7 +68,7 @@ type TSecretQueueFactoryDep = {
|
||||
folderDAL: TSecretFolderDALFactory;
|
||||
secretDAL: TSecretDALFactory;
|
||||
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
||||
webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">;
|
||||
webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "find" | "transaction" | "update" | "bulkUpdate" | "upsert">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "find">;
|
||||
projectDAL: TProjectDALFactory;
|
||||
projectBotDAL: TProjectBotDALFactory;
|
||||
@@ -79,6 +87,7 @@ type TSecretQueueFactoryDep = {
|
||||
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "deleteByProjectId">;
|
||||
snapshotDAL: Pick<TSnapshotDALFactory, "findNSecretV1SnapshotByFolderId" | "deleteSnapshotsAboveLimit">;
|
||||
snapshotSecretV2BridgeDAL: Pick<TSnapshotSecretV2DALFactory, "insertMany">;
|
||||
dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "find" | "upsert">;
|
||||
};
|
||||
|
||||
export type TGetSecrets = {
|
||||
@@ -122,7 +131,8 @@ export const secretQueueFactory = ({
|
||||
secretRotationDAL,
|
||||
snapshotDAL,
|
||||
snapshotSecretV2BridgeDAL,
|
||||
secretApprovalRequestDAL
|
||||
secretApprovalRequestDAL,
|
||||
dynamicSecretDAL
|
||||
}: TSecretQueueFactoryDep) => {
|
||||
const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => {
|
||||
const appCfg = getConfig();
|
||||
@@ -881,6 +891,37 @@ export const secretQueueFactory = ({
|
||||
await secretV2BridgeDAL.upsertSecretReferences(secretReferences, tx);
|
||||
}
|
||||
|
||||
const dynamicSecrets = await dynamicSecretDAL.find({ folderId }, { tx });
|
||||
if (dynamicSecrets.length) {
|
||||
await dynamicSecretDAL.upsert(
|
||||
dynamicSecrets.map((el) => {
|
||||
let { encryptedConfig } = el;
|
||||
if (!encryptedConfig) {
|
||||
if (el.keyEncoding && el.inputCiphertext && el.inputTag && el.inputIV) {
|
||||
const decryptedConfig = infisicalSymmetricDecrypt({
|
||||
keyEncoding: el.keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: el.inputCiphertext,
|
||||
tag: el.inputTag,
|
||||
iv: el.inputIV
|
||||
});
|
||||
encryptedConfig = secretManagerEncryptor({ plainText: Buffer.from(decryptedConfig) }).cipherTextBlob;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...el,
|
||||
encryptedConfig,
|
||||
keyEncoding: null,
|
||||
inputCiphertext: null,
|
||||
inputTag: null,
|
||||
inputIV: null,
|
||||
algorithm: null
|
||||
};
|
||||
}),
|
||||
"id",
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const SNAPSHOT_BATCH_SIZE = 15;
|
||||
const snapshots = await snapshotDAL.findNSecretV1SnapshotByFolderId(folderId, SNAPSHOT_BATCH_SIZE, tx);
|
||||
const projectV3SecretVersionsGroupById: Record<string, TSecretVersionsV2> = {};
|
||||
@@ -1110,6 +1151,71 @@ export const secretQueueFactory = ({
|
||||
tx
|
||||
);
|
||||
|
||||
/*
|
||||
* webhooks
|
||||
* */
|
||||
const projectV1Webhooks = await webhookDAL.find({ projectId }, tx);
|
||||
if (projectV1Webhooks.length) {
|
||||
await webhookDAL.upsert(
|
||||
projectV1Webhooks.map((el) => {
|
||||
let { encryptedSecretKeyWithKms, encryptedUrl } = el;
|
||||
if (!encryptedSecretKeyWithKms) {
|
||||
if (el.encryptedSecretKey && el.iv && el.tag) {
|
||||
const webhookSecretKey = infisicalSymmetricDecrypt({
|
||||
keyEncoding: el.keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: el.encryptedSecretKey,
|
||||
iv: el.iv,
|
||||
tag: el.tag
|
||||
});
|
||||
encryptedSecretKeyWithKms = secretManagerEncryptor({
|
||||
plainText: Buffer.from(webhookSecretKey)
|
||||
}).cipherTextBlob;
|
||||
}
|
||||
}
|
||||
if (!encryptedUrl) {
|
||||
if (el.urlTag && el.urlCipherText && el.urlIV) {
|
||||
const webhookUrl = infisicalSymmetricDecrypt({
|
||||
keyEncoding: el.keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: el.urlCipherText,
|
||||
iv: el.urlIV,
|
||||
tag: el.urlTag
|
||||
});
|
||||
encryptedUrl = secretManagerEncryptor({
|
||||
plainText: Buffer.from(webhookUrl)
|
||||
}).cipherTextBlob;
|
||||
} else {
|
||||
encryptedUrl = secretManagerEncryptor({
|
||||
plainText: Buffer.from(el.url)
|
||||
}).cipherTextBlob;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: el.id,
|
||||
url: el.url,
|
||||
envId: el.envId,
|
||||
type: el.type,
|
||||
isDisabled: el.isDisabled,
|
||||
lastStatus: el.lastStatus,
|
||||
secretPath: el.secretPath,
|
||||
lastRunErrorMessage: el.lastRunErrorMessage,
|
||||
encryptedSecretKeyWithKms,
|
||||
encryptedUrl,
|
||||
urlCipherText: null,
|
||||
urlIV: null,
|
||||
urlTag: null,
|
||||
encryptedSecretKey: null,
|
||||
iv: null,
|
||||
tag: null,
|
||||
keyEncoding: null,
|
||||
algorithm: null
|
||||
};
|
||||
}),
|
||||
"id",
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* approvals: we will delete all approvals this is because some secret versions may not be added yet
|
||||
* Thus doesn't make sense for rest to be there
|
||||
@@ -1193,7 +1299,7 @@ export const secretQueueFactory = ({
|
||||
});
|
||||
|
||||
queueService.start(QueueName.SecretWebhook, async (job) => {
|
||||
await fnTriggerWebhook({ ...job.data, projectEnvDAL, webhookDAL, projectDAL });
|
||||
await fnTriggerWebhook({ ...job.data, projectEnvDAL, webhookDAL, projectDAL, kmsService });
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,7 +20,7 @@ export const webhookDALFactory = (db: TDbClient) => {
|
||||
.select(tx.ref("projectId").withSchema(TableName.Environment))
|
||||
.select(selectAllTableCols(TableName.Webhook));
|
||||
|
||||
const find = async (filter: Partial<TWebhooks>, tx?: Knex) => {
|
||||
const find = async (filter: Partial<TWebhooks & { projectId: string }>, tx?: Knex) => {
|
||||
try {
|
||||
const docs = await webhookFindQuery(tx || db.replicaNode(), filter);
|
||||
return docs.map(({ envId, envSlug, envName, ...el }) => ({
|
||||
|
||||
@@ -3,12 +3,14 @@ import crypto from "node:crypto";
|
||||
import { AxiosError } from "axios";
|
||||
import picomatch from "picomatch";
|
||||
|
||||
import { SecretKeyEncoding, TWebhooks } from "@app/db/schemas";
|
||||
import { SecretKeyEncoding } from "@app/db/schemas";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TWebhookDALFactory } from "./webhook-dal";
|
||||
@@ -16,40 +18,12 @@ import { WebhookType } from "./webhook-types";
|
||||
|
||||
const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000;
|
||||
|
||||
export const decryptWebhookDetails = (webhook: TWebhooks) => {
|
||||
const { keyEncoding, iv, encryptedSecretKey, tag, urlCipherText, urlIV, urlTag, url } = webhook;
|
||||
|
||||
let decryptedSecretKey = "";
|
||||
let decryptedUrl = url;
|
||||
|
||||
if (encryptedSecretKey) {
|
||||
decryptedSecretKey = infisicalSymmetricDecrypt({
|
||||
keyEncoding: keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: encryptedSecretKey,
|
||||
iv: iv as string,
|
||||
tag: tag as string
|
||||
});
|
||||
}
|
||||
|
||||
if (urlCipherText) {
|
||||
decryptedUrl = infisicalSymmetricDecrypt({
|
||||
keyEncoding: keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: urlCipherText,
|
||||
iv: urlIV as string,
|
||||
tag: urlTag as string
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
secretKey: decryptedSecretKey,
|
||||
url: decryptedUrl
|
||||
};
|
||||
};
|
||||
|
||||
export const triggerWebhookRequest = async (webhook: TWebhooks, data: Record<string, unknown>) => {
|
||||
export const triggerWebhookRequest = async (
|
||||
{ webhookSecretKey: secretKey, webhookUrl: url }: { webhookSecretKey?: string; webhookUrl: string },
|
||||
data: Record<string, unknown>
|
||||
) => {
|
||||
const headers: Record<string, string> = {};
|
||||
const payload = { ...data, timestamp: Date.now() };
|
||||
const { secretKey, url } = decryptWebhookDetails(webhook);
|
||||
|
||||
if (secretKey) {
|
||||
const webhookSign = crypto.createHmac("sha256", secretKey).update(JSON.stringify(payload)).digest("hex");
|
||||
@@ -124,6 +98,7 @@ export type TFnTriggerWebhookDTO = {
|
||||
webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
// this is reusable function
|
||||
@@ -134,7 +109,8 @@ export const fnTriggerWebhook = async ({
|
||||
projectId,
|
||||
webhookDAL,
|
||||
projectEnvDAL,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
kmsService
|
||||
}: TFnTriggerWebhookDTO) => {
|
||||
const webhooks = await webhookDAL.findAllWebhooks(projectId, environment);
|
||||
const toBeTriggeredHooks = webhooks.filter(
|
||||
@@ -144,10 +120,38 @@ export const fnTriggerWebhook = async ({
|
||||
if (!toBeTriggeredHooks.length) return;
|
||||
logger.info("Secret webhook job started", { environment, secretPath, projectId });
|
||||
const project = await projectDAL.findById(projectId);
|
||||
const { decryptor: kmsDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
projectId,
|
||||
type: KmsDataKey.SecretManager
|
||||
});
|
||||
|
||||
const webhooksTriggered = await Promise.allSettled(
|
||||
toBeTriggeredHooks.map((hook) =>
|
||||
triggerWebhookRequest(
|
||||
hook,
|
||||
toBeTriggeredHooks.map((hook) => {
|
||||
let webhookUrl = hook.url;
|
||||
let webhookSecretKey;
|
||||
if (hook.urlTag && hook.urlCipherText && hook.urlIV) {
|
||||
webhookUrl = infisicalSymmetricDecrypt({
|
||||
keyEncoding: hook.keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: hook.urlCipherText,
|
||||
iv: hook.urlIV,
|
||||
tag: hook.urlTag
|
||||
});
|
||||
} else if (hook.encryptedUrl) {
|
||||
webhookUrl = kmsDataKeyDecryptor({ cipherTextBlob: hook.encryptedUrl }).toString();
|
||||
}
|
||||
if (hook.encryptedSecretKey && hook.iv && hook.tag) {
|
||||
webhookSecretKey = infisicalSymmetricDecrypt({
|
||||
keyEncoding: hook.keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: hook.encryptedSecretKey,
|
||||
iv: hook.iv,
|
||||
tag: hook.tag
|
||||
});
|
||||
} else if (hook.encryptedSecretKeyWithKms) {
|
||||
webhookSecretKey = kmsDataKeyDecryptor({ cipherTextBlob: hook.encryptedSecretKeyWithKms }).toString();
|
||||
}
|
||||
|
||||
return triggerWebhookRequest(
|
||||
{ webhookUrl, webhookSecretKey },
|
||||
getWebhookPayload("secrets.modified", {
|
||||
workspaceName: project.name,
|
||||
workspaceId: projectId,
|
||||
@@ -155,8 +159,8 @@ export const fnTriggerWebhook = async ({
|
||||
secretPath,
|
||||
type: hook.type
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// filter hooks by status
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { TWebhooksInsert } from "@app/db/schemas";
|
||||
import { SecretKeyEncoding, TWebhooksInsert } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TWebhookDALFactory } from "./webhook-dal";
|
||||
import { decryptWebhookDetails, getWebhookPayload, triggerWebhookRequest } from "./webhook-fns";
|
||||
import { getWebhookPayload, triggerWebhookRequest } from "./webhook-fns";
|
||||
import {
|
||||
TCreateWebhookDTO,
|
||||
TDeleteWebhookDTO,
|
||||
@@ -23,6 +25,7 @@ type TWebhookServiceFactoryDep = {
|
||||
projectEnvDAL: TProjectEnvDALFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
export type TWebhookServiceFactory = ReturnType<typeof webhookServiceFactory>;
|
||||
@@ -31,7 +34,8 @@ export const webhookServiceFactory = ({
|
||||
webhookDAL,
|
||||
projectEnvDAL,
|
||||
permissionService,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
kmsService
|
||||
}: TWebhookServiceFactoryDep) => {
|
||||
const createWebhook = async ({
|
||||
actor,
|
||||
@@ -64,22 +68,23 @@ export const webhookServiceFactory = ({
|
||||
type
|
||||
};
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
projectId,
|
||||
type: KmsDataKey.SecretManager
|
||||
});
|
||||
if (webhookSecretKey) {
|
||||
const { ciphertext, iv, tag, algorithm, encoding } = infisicalSymmetricEncypt(webhookSecretKey);
|
||||
insertDoc.encryptedSecretKey = ciphertext;
|
||||
insertDoc.iv = iv;
|
||||
insertDoc.tag = tag;
|
||||
insertDoc.algorithm = algorithm;
|
||||
insertDoc.keyEncoding = encoding;
|
||||
const encryptedSecretKeyWithKms = secretManagerEncryptor({
|
||||
plainText: Buffer.from(webhookSecretKey)
|
||||
}).cipherTextBlob;
|
||||
insertDoc.encryptedSecretKeyWithKms = encryptedSecretKeyWithKms;
|
||||
}
|
||||
|
||||
if (webhookUrl) {
|
||||
const { ciphertext, iv, tag, algorithm, encoding } = infisicalSymmetricEncypt(webhookUrl);
|
||||
insertDoc.urlCipherText = ciphertext;
|
||||
insertDoc.urlIV = iv;
|
||||
insertDoc.urlTag = tag;
|
||||
insertDoc.algorithm = algorithm;
|
||||
insertDoc.keyEncoding = encoding;
|
||||
const encryptedUrl = secretManagerEncryptor({
|
||||
plainText: Buffer.from(webhookUrl)
|
||||
}).cipherTextBlob;
|
||||
|
||||
insertDoc.encryptedUrl = encryptedUrl;
|
||||
}
|
||||
|
||||
const webhook = await webhookDAL.create(insertDoc);
|
||||
@@ -136,9 +141,35 @@ export const webhookServiceFactory = ({
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks);
|
||||
let webhookError: string | undefined;
|
||||
const { decryptor: kmsDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
projectId: project.id,
|
||||
type: KmsDataKey.SecretManager
|
||||
});
|
||||
let webhookUrl = webhook.url;
|
||||
let webhookSecretKey;
|
||||
if (webhook.urlTag && webhook.urlCipherText && webhook.urlIV) {
|
||||
webhookUrl = infisicalSymmetricDecrypt({
|
||||
keyEncoding: webhook.keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: webhook.urlCipherText,
|
||||
iv: webhook.urlIV,
|
||||
tag: webhook.urlTag
|
||||
});
|
||||
} else if (webhook.encryptedUrl) {
|
||||
webhookUrl = kmsDataKeyDecryptor({ cipherTextBlob: webhook.encryptedUrl }).toString();
|
||||
}
|
||||
if (webhook.encryptedSecretKey && webhook.iv && webhook.tag) {
|
||||
webhookSecretKey = infisicalSymmetricDecrypt({
|
||||
keyEncoding: webhook.keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: webhook.encryptedSecretKey,
|
||||
iv: webhook.iv,
|
||||
tag: webhook.tag
|
||||
});
|
||||
} else if (webhook.encryptedSecretKeyWithKms) {
|
||||
webhookSecretKey = kmsDataKeyDecryptor({ cipherTextBlob: webhook.encryptedSecretKeyWithKms }).toString();
|
||||
}
|
||||
try {
|
||||
await triggerWebhookRequest(
|
||||
webhook,
|
||||
{ webhookUrl, webhookSecretKey },
|
||||
getWebhookPayload("test", {
|
||||
workspaceName: project.name,
|
||||
workspaceId: webhook.projectId,
|
||||
@@ -177,11 +208,25 @@ export const webhookServiceFactory = ({
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks);
|
||||
|
||||
const webhooks = await webhookDAL.findAllWebhooks(projectId, environment, secretPath);
|
||||
const { decryptor: kmsDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
return webhooks.map((w) => {
|
||||
const { url } = decryptWebhookDetails(w);
|
||||
let decryptedUrl = w.url;
|
||||
if (w.urlTag && w.urlCipherText && w.urlIV) {
|
||||
decryptedUrl = infisicalSymmetricDecrypt({
|
||||
keyEncoding: w.keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: w.urlCipherText,
|
||||
iv: w.urlIV,
|
||||
tag: w.urlTag
|
||||
});
|
||||
} else if (w.encryptedUrl) {
|
||||
decryptedUrl = kmsDataKeyDecryptor({ cipherTextBlob: w.encryptedUrl }).toString();
|
||||
}
|
||||
return {
|
||||
...w,
|
||||
url
|
||||
url: decryptedUrl
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user