mirror of https://github.com/certd/certd
chore: auto
parent
4b335db31c
commit
785bee2b39
|
@ -1,6 +1,6 @@
|
||||||
import {Inject, Provide, Scope, ScopeEnum} from '@midwayjs/core';
|
import {Inject, Provide, Scope, ScopeEnum} from '@midwayjs/core';
|
||||||
import {InjectEntityModel} from '@midwayjs/typeorm';
|
import {InjectEntityModel} from '@midwayjs/typeorm';
|
||||||
import {Repository} from 'typeorm';
|
import { In, Repository } from "typeorm";
|
||||||
import {AccessGetter, BaseService, PageReq, PermissionException, ValidateException} from '../../../index.js';
|
import {AccessGetter, BaseService, PageReq, PermissionException, ValidateException} from '../../../index.js';
|
||||||
import {AccessEntity} from '../entity/access.js';
|
import {AccessEntity} from '../entity/access.js';
|
||||||
import {AccessDefine, accessRegistry, newAccess} from '@certd/pipeline';
|
import {AccessDefine, accessRegistry, newAccess} from '@certd/pipeline';
|
||||||
|
@ -175,4 +175,27 @@ export class AccessService extends BaseService<AccessEntity> {
|
||||||
getDefineByType(type: string) {
|
getDefineByType(type: string) {
|
||||||
return accessRegistry.getDefine(type);
|
return accessRegistry.getDefine(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async getSimpleByIds(ids: number[], userId: any) {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!userId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await this.repository.find({
|
||||||
|
where: {
|
||||||
|
id: In(ids),
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
userId:true
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,3 +59,32 @@ export interface ISubDomainsGetter {
|
||||||
export interface IDomainParser {
|
export interface IDomainParser {
|
||||||
parse(fullDomain: string): Promise<string>;
|
parse(fullDomain: string): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DnsVerifier = {
|
||||||
|
// dns直接校验
|
||||||
|
dnsProviderType: string;
|
||||||
|
dnsProviderAccessId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CnameVerifier = {
|
||||||
|
cnameRecord: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HttpVerifier = {
|
||||||
|
// http校验
|
||||||
|
httpUploaderType: string;
|
||||||
|
httpUploaderAccess: string;
|
||||||
|
httpUploadRootDir: string;
|
||||||
|
};
|
||||||
|
export type DomainVerifier = {
|
||||||
|
domain: string;
|
||||||
|
mainDomain: string;
|
||||||
|
challengeType: string;
|
||||||
|
dns?: DnsVerifier;
|
||||||
|
cname?: CnameVerifier;
|
||||||
|
http?: HttpVerifier;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IDomainVerifierGetter {
|
||||||
|
getVerifiers(domains: string[]): Promise<DomainVerifier[]>;
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { utils } from "@certd/basic";
|
||||||
import type { CertInfo, CnameVerifyPlan, DomainsVerifyPlan, HttpVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
|
import type { CertInfo, CnameVerifyPlan, DomainsVerifyPlan, HttpVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
|
||||||
import { AcmeService } from "./acme.js";
|
import { AcmeService } from "./acme.js";
|
||||||
import * as _ from "lodash-es";
|
import * as _ from "lodash-es";
|
||||||
import { createDnsProvider, DnsProviderContext, IDnsProvider, ISubDomainsGetter } from "../../dns-provider/index.js";
|
import { createDnsProvider, DnsProviderContext, DomainVerifier, IDnsProvider, IDomainVerifierGetter, ISubDomainsGetter } from "../../dns-provider/index.js";
|
||||||
import { CertReader } from "./cert-reader.js";
|
import { CertReader } from "./cert-reader.js";
|
||||||
import { CertApplyBasePlugin } from "./base.js";
|
import { CertApplyBasePlugin } from "./base.js";
|
||||||
import { GoogleClient } from "../../libs/google.js";
|
import { GoogleClient } from "../../libs/google.js";
|
||||||
|
@ -66,6 +66,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||||
{ value: "cname", label: "CNAME代理验证" },
|
{ value: "cname", label: "CNAME代理验证" },
|
||||||
{ value: "http", label: "HTTP文件验证" },
|
{ value: "http", label: "HTTP文件验证" },
|
||||||
{ value: "dnses", label: "多DNS提供商" },
|
{ value: "dnses", label: "多DNS提供商" },
|
||||||
|
{ value: "auto", label: "自动选择" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -73,6 +74,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||||
2. <b>CNAME代理验证</b>:支持任何注册商的域名,第一次需要手动添加CNAME记录(建议将DNS服务器修改为阿里云/腾讯云的,然后使用DNS直接验证)
|
2. <b>CNAME代理验证</b>:支持任何注册商的域名,第一次需要手动添加CNAME记录(建议将DNS服务器修改为阿里云/腾讯云的,然后使用DNS直接验证)
|
||||||
3. <b>HTTP文件验证</b>:不支持泛域名,需要配置网站文件上传
|
3. <b>HTTP文件验证</b>:不支持泛域名,需要配置网站文件上传
|
||||||
4. <b>多DNS提供商</b>:每个域名可以选择独立的DNS提供商
|
4. <b>多DNS提供商</b>:每个域名可以选择独立的DNS提供商
|
||||||
|
5. <b>自动选择</b>:需要在[域名管理](#/certd/cert/domain)中事先配置好校验方式
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
challengeType!: string;
|
challengeType!: string;
|
||||||
|
@ -408,7 +410,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||||
let dnsProvider: IDnsProvider = null;
|
let dnsProvider: IDnsProvider = null;
|
||||||
let domainsVerifyPlan: DomainsVerifyPlan = null;
|
let domainsVerifyPlan: DomainsVerifyPlan = null;
|
||||||
if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
|
if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
|
||||||
domainsVerifyPlan = await this.createDomainsVerifyPlan();
|
domainsVerifyPlan = await this.createDomainsVerifyPlan(this.domainsVerifyPlan);
|
||||||
|
}
|
||||||
|
if (this.challengeType === "auto") {
|
||||||
|
const planInput = await this.buildVerifyPlanInputByAuto();
|
||||||
|
domainsVerifyPlan = await this.createDomainsVerifyPlan(planInput);
|
||||||
} else {
|
} else {
|
||||||
const dnsProviderType = this.dnsProviderType;
|
const dnsProviderType = this.dnsProviderType;
|
||||||
const access = await this.getAccess(this.dnsProviderAccess);
|
const access = await this.getAccess(this.dnsProviderAccess);
|
||||||
|
@ -451,10 +457,10 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDomainsVerifyPlan(): Promise<DomainsVerifyPlan> {
|
async createDomainsVerifyPlan(verifyPlanSetting: DomainsVerifyPlanInput): Promise<DomainsVerifyPlan> {
|
||||||
const plan: DomainsVerifyPlan = {};
|
const plan: DomainsVerifyPlan = {};
|
||||||
for (const domain in this.domainsVerifyPlan) {
|
for (const domain in verifyPlanSetting) {
|
||||||
const domainVerifyPlan = this.domainsVerifyPlan[domain];
|
const domainVerifyPlan = verifyPlanSetting[domain];
|
||||||
let dnsProvider = null;
|
let dnsProvider = null;
|
||||||
const cnameVerifyPlan: Record<string, CnameVerifyPlan> = {};
|
const cnameVerifyPlan: Record<string, CnameVerifyPlan> = {};
|
||||||
const httpVerifyPlan: Record<string, HttpVerifyPlan> = {};
|
const httpVerifyPlan: Record<string, HttpVerifyPlan> = {};
|
||||||
|
@ -511,6 +517,66 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||||
}
|
}
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildVerifyPlanInputByAuto() {
|
||||||
|
//从数据库里面自动选择校验方式
|
||||||
|
// domain list
|
||||||
|
const domainList = new Set<string>();
|
||||||
|
//整理域名
|
||||||
|
for (let domain in this.domains) {
|
||||||
|
domain = domain.replaceAll("*.", "");
|
||||||
|
domainList.add(domain);
|
||||||
|
}
|
||||||
|
const domainVerifierGetter: IDomainVerifierGetter = await this.ctx.serviceGetter.get("DomainVerifierGetter");
|
||||||
|
|
||||||
|
const verifiers = await domainVerifierGetter.getVerifiers([...domainList]);
|
||||||
|
|
||||||
|
const verifyPlanInput: DomainsVerifyPlanInput = {};
|
||||||
|
|
||||||
|
for (const verifier of verifiers) {
|
||||||
|
const domain = verifier.domain;
|
||||||
|
const mainDomain = verifier.mainDomain;
|
||||||
|
let plan = verifyPlanInput[mainDomain];
|
||||||
|
if (!plan) {
|
||||||
|
plan = {
|
||||||
|
domain: mainDomain,
|
||||||
|
type: "cname",
|
||||||
|
};
|
||||||
|
verifyPlanInput[mainDomain] = plan;
|
||||||
|
}
|
||||||
|
if (verifier.challengeType === "cname") {
|
||||||
|
verifyPlanInput[domain] = {
|
||||||
|
type: "cname",
|
||||||
|
domain: domain,
|
||||||
|
cnameVerifyPlan: {
|
||||||
|
[domain]: {
|
||||||
|
id: 0,
|
||||||
|
status: "validate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (verifier.challengeType === "http") {
|
||||||
|
//http
|
||||||
|
const http = verifier.http;
|
||||||
|
verifyPlanInput[domain] = {
|
||||||
|
type: "http",
|
||||||
|
domain: domain,
|
||||||
|
httpVerifyPlan: {
|
||||||
|
[domain]: {
|
||||||
|
domain: domain,
|
||||||
|
httpUploaderType: http.httpUploaderType,
|
||||||
|
httpUploaderAccess: http.httpUploaderAccess,
|
||||||
|
httpUploadRootDir: http.httpUploadRootDir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
//dns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyPlanInput;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new CertApplyPlugin();
|
new CertApplyPlugin();
|
||||||
|
|
|
@ -9,9 +9,9 @@ export const Dicts = {
|
||||||
}),
|
}),
|
||||||
challengeTypeDict: dict({
|
challengeTypeDict: dict({
|
||||||
data: [
|
data: [
|
||||||
{ value: "dns", label: "DNS校验" },
|
{ value: "dns", label: "DNS校验", color: "green" },
|
||||||
{ value: "cname", label: "CNAME代理校验" },
|
{ value: "cname", label: "CNAME代理校验", color: "blue" },
|
||||||
{ value: "http", label: "HTTP校验" },
|
{ value: "http", label: "HTTP校验", color: "yellow" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
dnsProviderTypeDict: dict({
|
dnsProviderTypeDict: dict({
|
||||||
|
|
|
@ -55,6 +55,14 @@ export function createAccessApi(from = "user") {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async GetDictByIds(ids: number[]) {
|
||||||
|
return await request({
|
||||||
|
url: apiPrefix + "/getDictByIds",
|
||||||
|
method: "post",
|
||||||
|
data: { ids },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async GetSecretPlain(id: number, key: string) {
|
async GetSecretPlain(id: number, key: string) {
|
||||||
return await request({
|
return await request({
|
||||||
url: apiPrefix + "/getSecretPlain",
|
url: apiPrefix + "/getSecretPlain",
|
||||||
|
|
|
@ -5,8 +5,9 @@ import { useRouter } from "vue-router";
|
||||||
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
|
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
|
||||||
import { useUserStore } from "/@/store/user";
|
import { useUserStore } from "/@/store/user";
|
||||||
import { useSettingStore } from "/@/store/settings";
|
import { useSettingStore } from "/@/store/settings";
|
||||||
import { message } from "ant-design-vue";
|
|
||||||
import { Dicts } from "/@/components/plugins/lib/dicts";
|
import { Dicts } from "/@/components/plugins/lib/dicts";
|
||||||
|
import { createAccessApi } from "/@/views/certd/access/api";
|
||||||
|
|
||||||
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
|
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
@ -32,6 +33,21 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
const selectedRowKeys: Ref<any[]> = ref([]);
|
const selectedRowKeys: Ref<any[]> = ref([]);
|
||||||
context.selectedRowKeys = selectedRowKeys;
|
context.selectedRowKeys = selectedRowKeys;
|
||||||
|
|
||||||
|
const accessApi = createAccessApi();
|
||||||
|
const accessDict = dict({
|
||||||
|
value: "id",
|
||||||
|
label: "name",
|
||||||
|
url: "accessDict",
|
||||||
|
async getNodesByValues(ids: number[]) {
|
||||||
|
return await accessApi.GetDictByIds(ids);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpUploaderTypeDict = Dicts.uploaderTypeDict;
|
||||||
|
|
||||||
|
const dnsProviderTypeDict = dict({
|
||||||
|
url: "pi/dnsProvider/dnsProviderTypeDict",
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
crudOptions: {
|
crudOptions: {
|
||||||
settings: {
|
settings: {
|
||||||
|
@ -90,6 +106,9 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
column: {
|
||||||
|
sorter: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
challengeType: {
|
challengeType: {
|
||||||
title: t("certd.domain.challengeType"),
|
title: t("certd.domain.challengeType"),
|
||||||
|
@ -98,6 +117,9 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
form: {
|
form: {
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
column: {
|
||||||
|
sorter: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* challengeType varchar(50),
|
* challengeType varchar(50),
|
||||||
|
@ -109,7 +131,8 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
*/
|
*/
|
||||||
dnsProviderType: {
|
dnsProviderType: {
|
||||||
title: t("certd.domain.dnsProviderType"),
|
title: t("certd.domain.dnsProviderType"),
|
||||||
type: "text",
|
type: "dict-select",
|
||||||
|
dict: dnsProviderTypeDict,
|
||||||
form: {
|
form: {
|
||||||
component: {
|
component: {
|
||||||
name: "DnsProviderSelector",
|
name: "DnsProviderSelector",
|
||||||
|
@ -119,10 +142,17 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
}),
|
}),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
column: {
|
||||||
|
show: false,
|
||||||
|
component: {
|
||||||
|
color: "auto",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
dnsProviderAccess: {
|
dnsProviderAccess: {
|
||||||
title: t("certd.domain.dnsProviderAccess"),
|
title: t("certd.domain.dnsProviderAccess"),
|
||||||
type: "text",
|
type: "dict-select",
|
||||||
|
dict: accessDict,
|
||||||
form: {
|
form: {
|
||||||
component: {
|
component: {
|
||||||
name: "AccessSelector",
|
name: "AccessSelector",
|
||||||
|
@ -136,10 +166,16 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
}),
|
}),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
column: {
|
||||||
|
show: false,
|
||||||
|
component: {
|
||||||
|
color: "auto",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
httpUploaderType: {
|
httpUploaderType: {
|
||||||
title: t("certd.domain.httpUploaderType"),
|
title: t("certd.domain.httpUploaderType"),
|
||||||
type: "dict-text",
|
type: "dict-select",
|
||||||
dict: Dicts.uploaderTypeDict,
|
dict: Dicts.uploaderTypeDict,
|
||||||
form: {
|
form: {
|
||||||
show: compute(({ form }) => {
|
show: compute(({ form }) => {
|
||||||
|
@ -147,6 +183,12 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
}),
|
}),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
column: {
|
||||||
|
show: false,
|
||||||
|
component: {
|
||||||
|
color: "auto",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
httpUploaderAccess: {
|
httpUploaderAccess: {
|
||||||
title: t("certd.domain.httpUploaderAccess"),
|
title: t("certd.domain.httpUploaderAccess"),
|
||||||
|
@ -160,6 +202,12 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
}),
|
}),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
column: {
|
||||||
|
show: false,
|
||||||
|
component: {
|
||||||
|
color: "auto",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
httpUploadRootDir: {
|
httpUploadRootDir: {
|
||||||
title: t("certd.domain.httpUploadRootDir"),
|
title: t("certd.domain.httpUploadRootDir"),
|
||||||
|
@ -170,14 +218,47 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
}),
|
}),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
column: {
|
||||||
|
show: false,
|
||||||
|
component: {
|
||||||
|
color: "auto",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
challengeSetting: {
|
||||||
|
title: "校验配置",
|
||||||
|
type: "text",
|
||||||
|
form: { show: false },
|
||||||
|
column: {
|
||||||
|
width: 400,
|
||||||
|
conditionalRender: false,
|
||||||
|
cellRender({ row }) {
|
||||||
|
if (row.challengeType === "dns") {
|
||||||
|
return (
|
||||||
|
<div class={"flex"}>
|
||||||
|
<fs-values-format modelValue={row.dnsProviderType} dict={dnsProviderTypeDict} color={"auto"}></fs-values-format>
|
||||||
|
<fs-values-format class={"ml-5"} modelValue={row.dnsProviderAccess} dict={accessDict} color={"auto"}></fs-values-format>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (row.challengeType === "http") {
|
||||||
|
return (
|
||||||
|
<div class={"flex"}>
|
||||||
|
<fs-values-format modelValue={row.httpUploaderType} dict={httpUploaderTypeDict} color={"auto"}></fs-values-format>
|
||||||
|
<fs-values-format class={"ml-5"} modelValue={row.httpUploaderAccess} dict={accessDict} color={"auto"}></fs-values-format>
|
||||||
|
<a-tag class={"ml-5"}>{row.httpUploadRootDir}</a-tag>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
title: t("certd.domain.disabled"),
|
title: t("certd.domain.disabled"),
|
||||||
type: "dict-switch",
|
type: "dict-switch",
|
||||||
dict: dict({
|
dict: dict({
|
||||||
data: [
|
data: [
|
||||||
{ label: "启用", value: false },
|
{ label: "启用", value: false, color: "green" },
|
||||||
{ label: "禁用", value: true },
|
{ label: "禁用", value: true, color: "red" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
form: {
|
form: {
|
||||||
|
@ -185,7 +266,8 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
column: {
|
column: {
|
||||||
width: 80,
|
width: 100,
|
||||||
|
sorter: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createTime: {
|
createTime: {
|
||||||
|
|
|
@ -101,4 +101,10 @@ export class AccessController extends CrudController<AccessService> {
|
||||||
const res = await this.service.getSimpleInfo(id);
|
const res = await this.service.getSimpleInfo(id);
|
||||||
return this.ok(res);
|
return this.ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/getDictByIds', { summary: Constants.per.authOnly })
|
||||||
|
async getDictByIds(@Body('ids') ids: number[]) {
|
||||||
|
const res = await this.service.getSimpleByIds(ids, this.getUserId());
|
||||||
|
return this.ok(res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue