feat: Add automatic SSL certificate selection when creating a website with HTTPS enabled (#10789)

Optimize the default selection logic after the SSL list is loaded.
Add a flagging mechanism for manual SSL certificate selection, which disables automatic selection once triggered.
Add listeners for changes to the primary and more domains to trigger this automatic selection logic.
This commit is contained in:
maolei 2025-10-28 13:49:34 +08:00 committed by GitHub
parent ea878bba73
commit fddbf34684
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -354,7 +354,7 @@
<el-select
v-model="website.websiteSSLID"
:placeholder="$t('website.selectSSL')"
@change="changeSSl(website.websiteSSLID)"
@change="handleSSLSelectChange"
>
<el-option
v-for="(ssl, index) in ssls"
@ -436,7 +436,7 @@ import {
import { Rules, checkNumberRange } from '@/global/form-rules';
import i18n from '@/lang';
import { ElForm, FormInstance } from 'element-plus';
import { reactive, ref } from 'vue';
import { reactive, ref, watch } from 'vue';
import { MsgError, MsgSuccess } from '@/utils/message';
import { SearchRuntimes } from '@/api/modules/runtime';
import { Runtime } from '@/api/interface/runtime';
@ -448,6 +448,13 @@ import { Website } from '@/api/interface/website';
import { getPathByType } from '@/api/modules/files';
import { getWebsiteTypes } from '@/global/mimetype';
type SSLItem = Website.SSLDTO & {
organization?: string;
acmeAccount?: {
email?: string;
};
};
const websiteForm = ref<FormInstance>();
const initData = () => ({
@ -557,8 +564,9 @@ const versionExist = ref(true);
const em = defineEmits(['close']);
const taskLog = ref();
const dbServices = ref();
const ssls = ref();
const websiteSSL = ref();
const ssls = ref<SSLItem[]>([]);
const websiteSSL = ref<SSLItem | undefined>(undefined);
const userSelectedSSL = ref(false);
const parentWebsites = ref();
const dirs = ref([]);
const runtimePorts = ref([]);
@ -735,32 +743,184 @@ const listAcmeAccount = () => {
});
};
const changeSSl = (sslid?: number) => {
if (!sslid) {
websiteSSL.value = undefined;
return;
}
const selected = ssls.value.find((element) => element.id === sslid);
websiteSSL.value = selected;
};
const applySSLSelection = (sslId: number | undefined, markManual = false) => {
if (markManual) {
userSelectedSSL.value = true;
}
if (!sslId) {
website.value.websiteSSLID = undefined;
websiteSSL.value = undefined;
return;
}
website.value.websiteSSLID = sslId;
changeSSl(sslId);
};
const selectFirstAvailableSSL = () => {
const fallback = ssls.value.find((item) => item.pem !== '');
if (fallback) {
applySSLSelection(fallback.id);
}
};
const normalizeDomain = (domain?: string) => {
if (!domain) {
return '';
}
return domain.split(':')[0].trim().toLowerCase();
};
const wildcardMatches = (pattern: string, target: string) => {
if (!pattern.startsWith('*.')) {
return false;
}
const suffix = pattern.slice(1);
if (!suffix) {
return false;
}
if (!target.endsWith(suffix)) {
return false;
}
const suffixLabels = suffix.slice(1).split('.');
const targetLabels = target.split('.');
return targetLabels.length > suffixLabels.length;
};
const domainMatches = (pattern: string, target: string) => {
if (!pattern || !target) {
return false;
}
if (pattern === target) {
return true;
}
return wildcardMatches(pattern, target);
};
const getWebsiteDomains = (): string[] => {
const domains = new Set<string>();
const pushDomain = (value?: string) => {
const normalized = normalizeDomain(value);
if (normalized) {
domains.add(normalized);
}
};
pushDomain(website.value.primaryDomain);
if (Array.isArray(website.value.domains)) {
website.value.domains.forEach((item: any) => {
pushDomain(item?.domain);
});
}
return Array.from(domains);
};
const getCertificateDomains = (ssl: SSLItem): string[] => {
const domains = new Set<string>();
const tokens: string[] = [];
if (ssl.primaryDomain) {
tokens.push(ssl.primaryDomain);
}
if (ssl.domains) {
ssl.domains
.replace(/\n/g, ',')
.split(',')
.map((item) => item.trim())
.filter((item) => item !== '')
.forEach((item) => tokens.push(item));
}
tokens.forEach((token) => {
const normalized = normalizeDomain(token);
if (normalized) {
domains.add(normalized);
}
});
return Array.from(domains);
};
const tryAutoSelectSSL = (): boolean => {
if (!website.value.enableSSL) {
return false;
}
if (userSelectedSSL.value) {
return false;
}
if (!ssls.value.length) {
return false;
}
const siteDomains = getWebsiteDomains();
if (!siteDomains.length) {
return false;
}
const candidates = ssls.value
.filter((ssl) => ssl.pem !== '')
.map((ssl) => {
const certDomains = getCertificateDomains(ssl);
if (!certDomains.length) {
return { ssl, ratio: 0, matchCount: 0 };
}
const matchCount = certDomains.reduce((count, domain) => {
return count + (siteDomains.some((candidate) => domainMatches(domain, candidate)) ? 1 : 0);
}, 0);
const ratio = certDomains.length > 0 ? matchCount / certDomains.length : 0;
return { ssl, ratio, matchCount };
})
.filter((item) => item.matchCount > 0 && item.ratio > 0);
if (!candidates.length) {
return false;
}
candidates.sort((a, b) => {
if (b.ratio !== a.ratio) {
return b.ratio - a.ratio;
}
if (b.matchCount !== a.matchCount) {
return b.matchCount - a.matchCount;
}
return b.ssl.id - a.ssl.id;
});
const best = candidates[0];
if (!best) {
return false;
}
if (website.value.websiteSSLID !== best.ssl.id) {
applySSLSelection(best.ssl.id);
} else {
changeSSl(best.ssl.id);
}
return true;
};
const handleSSLSelectChange = (sslId: number | undefined) => {
applySSLSelection(sslId, true);
};
const listSSLs = () => {
listSSL({
acmeAccountID: String(website.value.acmeAccountID),
}).then((res) => {
ssls.value = res.data || [];
website.value.websiteSSLID = undefined;
websiteSSL.value = {};
if (ssls.value.length > 0) {
for (const ssl of ssls.value) {
if (ssl.pem != '') {
website.value.websiteSSLID = ssl.id;
changeSSl(website.value.websiteSSLID);
break;
}
}
websiteSSL.value = undefined;
userSelectedSSL.value = false;
const autoSelected = tryAutoSelectSSL();
if (!autoSelected) {
selectFirstAvailableSSL();
}
});
};
const changeSSl = (sslid: number) => {
const res = ssls.value.filter((element: Website.SSL) => {
return element.id == sslid;
});
websiteSSL.value = res[0];
};
const submit = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
await formEl.validate(async (valid) => {
@ -805,14 +965,34 @@ const submit = async (formEl: FormInstance | undefined) => {
watch(
() => website.value.domains,
(value) => {
if (value.length > 0) {
if (value && value.length > 0) {
const firstDomain = value[0].domain;
changeAlias(firstDomain);
}
tryAutoSelectSSL();
},
{ deep: true },
);
watch(
() => website.value.primaryDomain,
() => {
tryAutoSelectSSL();
},
);
watch(
() => website.value.enableSSL,
(enabled) => {
if (!enabled) {
applySSLSelection(undefined);
userSelectedSSL.value = false;
return;
}
tryAutoSelectSSL();
},
);
const changeAlias = (value: string) => {
const domain = value.split(':')[0];
website.value.alias = domain;