Compare commits

...
18 changed files with 253 additions and 133 deletions
+90 -26
View File
@@ -666,15 +666,59 @@ func (s *MCPWebsiteBindingService) matchesInitialSite(req request.MCPWebsiteBind
if err != nil || (site.Protocol == constant.ProtocolHTTPS) != req.EnableSSL || site.WebsiteSSLID != req.SSLID {
return false
}
if port == 0 {
explicitPort := port != 0
if !explicitPort || req.EnableSSL {
installed, installErr := s.openresty()
if installErr != nil {
return false
}
port = installed.HttpPort
if !explicitPort {
port = installed.HttpPort
}
if req.EnableSSL && port == installed.HttpPort {
for _, domain := range site.Domains {
if domain.Domain == host && domain.Port == installed.HttpPort && !domain.SSL {
return true // Complete an interrupted bind against its existing HTTP base.
}
}
}
}
for _, domain := range site.Domains {
if domain.Domain == host && domain.Port == port && !domain.SSL {
if domain.Domain == host && domain.Port == port && domain.SSL == (req.EnableSSL && explicitPort) {
return true
}
}
return false
}
func mcpWebsiteListenConflict(candidate model.Website, installed model.AppInstall) error {
if err := checkWebsiteListenConflict(candidate, installed.HttpPort, installed.HttpsPort); err != nil {
return err
}
sites, err := websiteRepo.List()
if err != nil {
return err
}
ports := websiteListenPorts(candidate, installed.HttpPort, installed.HttpsPort)
isTLS := func(site model.Website, port int) bool {
return site.Protocol == constant.ProtocolHTTPS && slices.Contains(buildWebsiteTLSPlan(site.Domains, installed.HttpPort, installed.HttpsPort).httpsPorts, port)
}
for _, site := range sites {
if site.ID == candidate.ID || site.Type == constant.Stream {
continue
}
for port := range websiteListenPorts(site, installed.HttpPort, installed.HttpsPort) {
if _, exists := ports[port]; exists && isTLS(candidate, port) != isTLS(site, port) {
return buserr.WithName("ErrDomainIsUsed", site.PrimaryDomain)
}
}
}
return nil
}
func mcpWebsiteTLSUsesHTTPPort(site model.Website, installed model.AppInstall) bool {
for _, domain := range site.Domains {
if domain.SSL && domain.Port == installed.HttpPort {
return true
}
}
@@ -835,6 +879,14 @@ func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWe
if req.DeleteWebsite {
return s.missingDeletedWebsite(req)
}
if req.Operation == "status" && req.WebsiteID == 0 {
partial := model.Website{Alias: mcpWebsiteAlias(req.BindingID)}
for _, location := range []string{GetSitePath(partial, SiteDir), GetSitePath(partial, SiteConf), mcpWebsitePrivatePath(partial, mcpWebsiteOwnerName), mcpWebsitePrivatePath(partial, mcpWebsiteProxyName)} {
if _, err := os.Lstat(location); !os.IsNotExist(err) {
return response.MCPWebsiteBinding{State: "unavailable", BindingID: req.BindingID, RequestID: req.RequestID, Reason: "website_missing"}
}
}
}
if req.WebsiteID == 0 || req.Operation == "unbind" {
return response.MCPWebsiteBinding{State: "unbound", BindingID: req.BindingID, RequestID: req.RequestID}
}
@@ -843,6 +895,12 @@ func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWe
if req.Operation != "bind" {
return mcpWebsiteFailure("website_missing", true, true)
}
if req.WebsiteID != 0 {
return mcpWebsiteFailure("website_missing", true, true)
}
if _, ownerErr := os.Lstat(mcpWebsitePrivatePath(model.Website{Alias: mcpWebsiteAlias(req.BindingID)}, mcpWebsiteOwnerName)); !os.IsNotExist(ownerErr) {
return mcpWebsiteFailure("website_ownership_changed", false, true)
}
installed, installErr := s.openresty()
if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
return mcpWebsiteFailure("openresty_unavailable", true, false)
@@ -852,17 +910,19 @@ func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWe
return mcpWebsiteFailure("website_group_unavailable", true, false)
}
domain, port, _ := mcpWebsiteDomain(req.Domain)
explicitPort := port != 0
if port == 0 {
port = installed.HttpPort
}
if req.EnableSSL && port != installed.HttpPort {
return mcpWebsiteFailure("http_port_requires_default", true, false)
}
candidate := model.Website{Protocol: constant.ProtocolHTTP, Domains: []model.WebsiteDomain{{Domain: domain, Port: port}}}
domainSSL := req.EnableSSL && explicitPort
candidate := model.Website{Protocol: constant.ProtocolHTTP, Domains: []model.WebsiteDomain{{Domain: domain, Port: port, SSL: domainSSL}}}
if req.EnableSSL {
candidate.Protocol = constant.ProtocolHTTPS
if mcpWebsiteTLSUsesHTTPPort(candidate, installed) {
return mcpWebsiteFailure("invalid_https_port", true, false)
}
}
if conflictErr := checkWebsiteListenConflict(candidate, installed.HttpPort, installed.HttpsPort); conflictErr != nil {
if conflictErr := mcpWebsiteListenConflict(candidate, installed); conflictErr != nil {
var business buserr.BusinessError
if errors.As(conflictErr, &business) && business.Msg == "ErrDomainIsUsed" {
return mcpWebsiteFailure("domain_conflict", true, true)
@@ -870,7 +930,7 @@ func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWe
return mcpWebsiteFailure("website_unavailable", false, false)
}
create := request.WebsiteCreate{Alias: mcpWebsiteAlias(req.BindingID), Type: constant.Static, Remark: mcpWebsiteRemark(req), WebsiteGroupID: group.ID,
Domains: []request.WebsiteDomain{{Domain: domain, Port: port}}, SSLConfig: request.SSLConfig{EnableSSL: req.EnableSSL, WebsiteSSLID: req.SSLID}}
Domains: []request.WebsiteDomain{{Domain: domain, Port: port, SSL: domainSSL}}, SSLConfig: request.SSLConfig{EnableSSL: req.EnableSSL, WebsiteSSLID: req.SSLID}}
previousMutation := mutationStarted
mutationStarted = true
if createErr := s.createWebsite(create); createErr != nil {
@@ -966,6 +1026,27 @@ func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWe
if req.Operation == "bind" && owner.BindHash != mcpWebsiteBindHash(req) {
return mcpWebsiteFailure("binding_request_changed", true, true)
}
if req.Operation == "update" && (site.Protocol == constant.ProtocolHTTPS) != req.EnableSSL {
installed, installErr := s.openresty()
if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
return mcpWebsiteFailure("openresty_unavailable", true, false)
}
candidate := site
candidate.Protocol = constant.ProtocolHTTP
if req.EnableSSL {
candidate.Protocol = constant.ProtocolHTTPS
if mcpWebsiteTLSUsesHTTPPort(candidate, installed) {
return mcpWebsiteFailure("invalid_https_port", true, false)
}
}
if conflictErr := mcpWebsiteListenConflict(candidate, installed); conflictErr != nil {
var business buserr.BusinessError
if errors.As(conflictErr, &business) && business.Msg == "ErrDomainIsUsed" {
return mcpWebsiteFailure("domain_conflict", true, true)
}
return mcpWebsiteFailure("website_unavailable", false, false)
}
}
if owner.ProxyHash != "" && owner.Operation != "unbind" && req.Operation != "unbind" && !mcpWebsiteProxyIncluded(site) {
return mcpWebsiteFailure("proxy_not_loaded", true, true)
}
@@ -1039,23 +1120,6 @@ func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWe
if req.Domain != site.PrimaryDomain {
return mcpWebsiteFailure("domain_changed", true, true)
}
if req.EnableSSL {
installed, installErr := s.openresty()
if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
return mcpWebsiteFailure("openresty_unavailable", true, false)
}
host, _, _ := mcpWebsiteDomain(site.PrimaryDomain)
usesHTTPPort := false
for _, domain := range site.Domains {
if domain.Domain == host && domain.Port == installed.HttpPort && !domain.SSL {
usesHTTPPort = true
break
}
}
if !usesHTTPPort {
return mcpWebsiteFailure("http_port_requires_default", true, false)
}
}
if site.Protocol == constant.ProtocolHTTPS && !req.EnableSSL || site.Protocol != constant.ProtocolHTTPS && req.EnableSSL || req.EnableSSL && site.WebsiteSSLID != req.SSLID {
mutationStarted = true
if err = s.updateHTTPS(context.WithoutCancel(ctx), request.WebsiteHTTPSOp{WebsiteID: site.ID, Enable: req.EnableSSL, Type: constant.SSLExisted, WebsiteSSLID: req.SSLID, HttpConfig: constant.HTTPSOnly}); err != nil {
+1
View File
@@ -410,6 +410,7 @@ func (s *MCPRuntimeService) mutateWebsiteLocked(ctx context.Context, pending dto
(status.WebsiteID == 0 && status.State != "unbound") {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("MCP_CONFLICT", "website_state_unconfirmed", false)
}
row.WebsiteID = status.WebsiteID
}
if pending.Operation == "bind" {
if row.WebsiteID != 0 {
+31 -18
View File
@@ -23,8 +23,9 @@ const isCsrfForbidden = (response?: AxiosResponse<any>) => {
return typeof message === 'string' && message.toLowerCase().includes('csrf token invalid');
};
type RequestConfig = AxiosRequestConfig & {
export type RequestConfig = AxiosRequestConfig & {
skipErrorMessage?: boolean;
errorMessageMode?: 'default' | 'manual';
};
class RequestHttp {
@@ -79,7 +80,8 @@ class RequestHttp {
async (response: AxiosResponse) => {
const globalStore = GlobalStore();
const { data } = response;
const authResult = handleAuthResponseCode(data, { showRBACMessage: true });
const manualErrorMessage = (response.config as RequestConfig)?.errorMessageMode === 'manual';
const authResult = handleAuthResponseCode(data, { showRBACMessage: !manualErrorMessage });
if (authResult.handled) {
if (authResult.action === 'return') {
return;
@@ -117,12 +119,14 @@ class RequestHttp {
return data;
}
if (data.code && data.code !== ResultEnum.SUCCESS) {
if (data.message.toLowerCase().indexOf('operation not permitted') !== -1) {
MsgError(i18n.global.t('license.tamperHelper'));
return Promise.reject(data);
}
if (!(response.config as RequestConfig).skipErrorMessage) {
MsgError(data.message);
if (!manualErrorMessage) {
if (data.message.toLowerCase().indexOf('operation not permitted') !== -1) {
MsgError(i18n.global.t('license.tamperHelper'));
return Promise.reject(data);
}
if (!(response.config as RequestConfig).skipErrorMessage) {
MsgError(data.message);
}
}
return Promise.reject(data);
}
@@ -130,8 +134,11 @@ class RequestHttp {
},
async (error: AxiosError) => {
const { response } = error;
const manualErrorMessage =
((error.config ?? response?.config) as RequestConfig | undefined)?.errorMessageMode === 'manual';
if (error.message.indexOf('timeout') !== -1) MsgError(i18n.global.t('commons.msg.requestTimeout'));
if (!manualErrorMessage && error.message.indexOf('timeout') !== -1)
MsgError(i18n.global.t('commons.msg.requestTimeout'));
if (response) {
switch (response.status) {
case 313:
@@ -141,20 +148,24 @@ class RequestHttp {
if (isCsrfForbidden(response)) {
return Promise.reject(error);
}
if (response.data && response.data['message']) {
MsgError(response.data['message']);
} else {
MsgError(i18n.global.t('commons.res.forbidden'));
if (!manualErrorMessage) {
if (response.data && response.data['message']) {
MsgError(response.data['message']);
} else {
MsgError(i18n.global.t('commons.res.forbidden'));
}
}
return Promise.reject(error);
case 500:
case 502:
case 524:
case 407:
checkStatus(
response.status,
response.data && response.data['message'] ? response.data['message'] : '',
);
if (!manualErrorMessage) {
checkStatus(
response.status,
response.data && response.data['message'] ? response.data['message'] : '',
);
}
return Promise.reject(error);
default:
return Promise.reject(error);
@@ -189,12 +200,14 @@ class RequestHttp {
...config,
});
}
postLocalNode<T>(url: string, params?: object, timeout?: number): Promise<ResultData<T>> {
postLocalNode<T>(url: string, params?: object, timeout?: number, config?: RequestConfig): Promise<ResultData<T>> {
return this.service.post(url, params, {
baseURL: import.meta.env.VITE_API_URL as string,
timeout: timeout ? timeout : (ResultEnum.TIMEOUT as number),
withCredentials: true,
...config,
headers: {
...config?.headers,
CurrentNode: 'local',
},
});
+5 -4
View File
@@ -5,18 +5,19 @@ import type { Container } from '@/api/interface/container';
const prefix = '/core/mcp';
const local = { headers: { CurrentNode: 'local' } };
const website = { ...local, errorMessageMode: 'manual' as const };
export const getPanelMCPStatus = () => http.get<PanelMCP.ServiceStatus>(`${prefix}/status`, {}, local);
export const getPanelMCPDockerStatus = () =>
http.get<Container.DockerStatus>('/containers/docker/status', {}, { ...local, timeout: 5000 });
export const getPanelMCPSettings = () => http.get<PanelMCP.Settings>(`${prefix}/settings`, {}, local);
export const getPanelMCPWebsite = () => http.get<PanelMCP.WebsiteBinding>(`${prefix}/website/status`, {}, local);
export const getPanelMCPWebsite = () => http.get<PanelMCP.WebsiteBinding>(`${prefix}/website/status`, {}, website);
export const bindPanelMCPWebsite = (params: PanelMCP.WebsiteBind) =>
http.postWithConfig<PanelMCP.WebsiteBinding>(`${prefix}/website/bind`, params, { ...local, timeout: 120000 });
http.postWithConfig<PanelMCP.WebsiteBinding>(`${prefix}/website/bind`, params, { ...website, timeout: 120000 });
export const updatePanelMCPWebsite = (params: PanelMCP.WebsiteUpdate) =>
http.postWithConfig<PanelMCP.WebsiteBinding>(`${prefix}/website/update`, params, { ...local, timeout: 120000 });
http.postWithConfig<PanelMCP.WebsiteBinding>(`${prefix}/website/update`, params, { ...website, timeout: 120000 });
export const unbindPanelMCPWebsite = (params: PanelMCP.WebsiteUnbind) =>
http.postWithConfig<PanelMCP.WebsiteBinding>(`${prefix}/website/unbind`, params, { ...local, timeout: 120000 });
http.postWithConfig<PanelMCP.WebsiteBinding>(`${prefix}/website/unbind`, params, { ...website, timeout: 120000 });
export const savePanelMCPSettings = (params: PanelMCP.Settings) =>
http.postWithConfig(`${prefix}/settings`, params, { ...local, timeout: 60000 });
export const operatePanelMCPService = (operation: PanelMCP.Operation) =>
+3 -3
View File
@@ -1,4 +1,4 @@
import http from '@/api';
import http, { type RequestConfig } from '@/api';
import { ReqPage, ResPage } from '../interface';
import { Website } from '../interface/website';
import { File } from '../interface/file';
@@ -124,8 +124,8 @@ export const listSSL = (req: Website.SSLReq) => {
return http.post<Website.SSLDTO[]>(`/websites/ssl/list`, req);
};
export const listLocalNodeSSL = (req: Website.SSLReq) => {
return http.postLocalNode<Website.SSLDTO[]>(`/websites/ssl/list`, req);
export const listLocalNodeSSL = (req: Website.SSLReq, config?: RequestConfig) => {
return http.postLocalNode<Website.SSLDTO[]>(`/websites/ssl/list`, req, undefined, config);
};
export const createSSL = (req: Website.SSLCreate) => {
+1
View File
@@ -1811,6 +1811,7 @@ const message = {
websiteUnavailable: 'Binding unavailable',
websiteDomainInvalid: 'Enter a valid domain, optionally including a port.',
websiteCertificateRequired: 'Select a valid certificate covering this domain.',
websiteCertificatesLoadFailed: 'Cannot load certificates. Refresh to retry.',
websiteCertificateEmpty: 'No matching certificate. Apply for or import one.',
websiteUnbind: 'Unbind and delete website',
websiteUnbindConfirm:
+1
View File
@@ -1826,6 +1826,7 @@ const message = {
websiteUnavailable: 'Vinculación no disponible',
websiteDomainInvalid: 'Introduzca un dominio válido, con un puerto opcional.',
websiteCertificateRequired: 'Seleccione un certificado válido que cubra este dominio.',
websiteCertificatesLoadFailed: 'No se pueden cargar los certificados. Actualice para reintentar.',
websiteCertificateEmpty: 'No hay un certificado coincidente. Solicite o importe uno.',
websiteUnbind: 'Desvincular y eliminar sitio web',
websiteUnbindConfirm:
+1
View File
@@ -1792,6 +1792,7 @@ const message = {
websiteUnavailable: 'اتصال در دسترس نیست',
websiteDomainInvalid: 'یک دامنه معتبر وارد کنید؛ می‌توانید پورت را نیز مشخص کنید.',
websiteCertificateRequired: 'یک گواهی معتبر برای این دامنه انتخاب کنید.',
websiteCertificatesLoadFailed: 'بارگیری فهرست گواهی‌ها ناموفق بود. تازه‌سازی کرده و دوباره تلاش کنید.',
websiteCertificateEmpty: 'گواهی منطبق وجود ندارد. گواهی درخواست یا وارد کنید.',
websiteUnbind: 'قطع اتصال و حذف وب‌سایت',
websiteUnbindConfirm: 'اتصال قطع و سایت و پوشه آن حذف شود؟ گواهی حفظ می‌شود. اتصال مجدد سایت تازه‌ای می‌سازد.',
+1
View File
@@ -1794,6 +1794,7 @@ const message = {
websiteUnavailable: '関連付けを利用できません',
websiteDomainInvalid: '有効なドメインを入力してください。ポートも指定できます。',
websiteCertificateRequired: 'このドメインを対象とする有効な証明書を選択してください。',
websiteCertificatesLoadFailed: '証明書一覧を読み込めません。更新して再試行してください。',
websiteCertificateEmpty: '一致する証明書がありません。申請またはインポートしてください。',
websiteUnbind: '関連付けを解除してサイトを削除',
websiteUnbindConfirm:
+1
View File
@@ -1778,6 +1778,7 @@ const message = {
websiteUnavailable: '연결 사용 불가',
websiteDomainInvalid: '유효한 도메인을 입력하세요. 포트를 포함할 수 있습니다.',
websiteCertificateRequired: '이 도메인에 유효한 인증서를 선택하세요.',
websiteCertificatesLoadFailed: '인증서 목록을 불러올 수 없습니다. 새로 고침 후 다시 시도하세요.',
websiteCertificateEmpty: '일치하는 인증서가 없습니다. 신청하거나 가져오세요.',
websiteUnbind: '연결 해제 및 웹사이트 삭제',
websiteUnbindConfirm:
+1
View File
@@ -1768,6 +1768,7 @@ const message = {
websiteUnavailable: 'ການຜູກບໍ່ພ້ອມໃຊ້',
websiteDomainInvalid: 'ປ້ອນໂດເມນທີ່ຖືກຕ້ອງ ໂດຍສາມາດລະບຸພອດໄດ້.',
websiteCertificateRequired: 'ເລືອກໃບຢັ້ງຢືນທີ່ຍັງໃຊ້ໄດ້ ແລະຄອບຄຸມໂດເມນນີ້.',
websiteCertificatesLoadFailed: 'ບໍ່ສາມາດໂຫຼດລາຍການໃບຮັບຮອງໄດ້. ກະລຸນາໂຫຼດໜ້າໃໝ່ແລ້ວລອງອີກຄັ້ງ.',
websiteCertificateEmpty: 'ບໍ່ມີໃບຮັບຮອງທີ່ກົງກັນ. ຂໍ ຫຼືນຳເຂົ້າໃບຮັບຮອງ.',
websiteUnbind: 'ຍົກເລີກການຜູກ ແລະ ລຶບເວັບໄຊ',
websiteUnbindConfirm: 'ຍົກເລີກການຜູກ ແລະລຶບເວັບໄຊພ້ອມໂຟນເດີບໍ? ເກັບໃບຮັບຮອງໄວ້. ຜູກໃໝ່ຈະສ້າງເວັບໄຊໃໝ່.',
+1
View File
@@ -1825,6 +1825,7 @@ const message = {
websiteUnavailable: 'Pautan tidak tersedia',
websiteDomainInvalid: 'Masukkan domain yang sah, dengan port pilihan.',
websiteCertificateRequired: 'Pilih sijil sah yang meliputi domain ini.',
websiteCertificatesLoadFailed: 'Tidak dapat memuatkan senarai sijil. Muat semula untuk mencuba lagi.',
websiteCertificateEmpty: 'Tiada sijil sepadan. Mohon atau import sijil.',
websiteUnbind: 'Nyahpaut dan padam laman web',
websiteUnbindConfirm:
+1
View File
@@ -1825,6 +1825,7 @@ const message = {
websiteUnavailable: 'Vinculação indisponível',
websiteDomainInvalid: 'Informe um domínio válido, com uma porta opcional.',
websiteCertificateRequired: 'Selecione um certificado válido que cubra este domínio.',
websiteCertificatesLoadFailed: 'Não foi possível carregar os certificados. Atualize para tentar novamente.',
websiteCertificateEmpty: 'Nenhum certificado correspondente. Solicite ou importe um.',
websiteUnbind: 'Desvincular e excluir site',
websiteUnbindConfirm:
+1
View File
@@ -1815,6 +1815,7 @@ const message = {
websiteUnavailable: 'Привязка недоступна',
websiteDomainInvalid: 'Введите допустимый домен, при необходимости с портом.',
websiteCertificateRequired: 'Выберите действующий сертификат для этого домена.',
websiteCertificatesLoadFailed: 'Не удалось загрузить список сертификатов. Обновите и повторите попытку.',
websiteCertificateEmpty: 'Нет подходящего сертификата. Выпустите или импортируйте его.',
websiteUnbind: 'Отвязать и удалить сайт',
websiteUnbindConfirm:
+1
View File
@@ -1820,6 +1820,7 @@ const message = {
websiteUnavailable: 'Bağlantı kullanılamıyor',
websiteDomainInvalid: 'Geçerli bir alan adı girin; isteğe bağlı olarak bağlantı noktası ekleyebilirsiniz.',
websiteCertificateRequired: 'Bu alan adını kapsayan geçerli bir sertifika seçin.',
websiteCertificatesLoadFailed: 'Sertifika listesi yüklenemedi. Yenileyip tekrar deneyin.',
websiteCertificateEmpty: 'Uygun sertifika yok. Başvurun veya içe aktarın.',
websiteUnbind: 'Bağlantıyı kaldır ve siteyi sil',
websiteUnbindConfirm:
+1
View File
@@ -1717,6 +1717,7 @@ const message = {
websiteUnavailable: '綁定不可用',
websiteDomainInvalid: '請輸入有效網域,可包含連接埠。',
websiteCertificateRequired: '請選擇涵蓋此網域且有效的憑證。',
websiteCertificatesLoadFailed: '憑證清單讀取失敗,請重新整理後重試。',
websiteCertificateEmpty: '無符合憑證,請先申請或匯入。',
websiteUnbind: '解除綁定並刪除網站',
websiteUnbindConfirm: '解除綁定並刪除網站及目錄?憑證保留,重新綁定將建立新網站。',
+1
View File
@@ -1731,6 +1731,7 @@ const message = {
websiteUnavailable: '绑定不可用',
websiteDomainInvalid: '请输入有效域名,可包含端口。',
websiteCertificateRequired: '请选择覆盖该域名且有效的证书。',
websiteCertificatesLoadFailed: '证书列表读取失败,请刷新重试。',
websiteCertificateEmpty: '暂无匹配证书,请先申请或导入。',
websiteUnbind: '解绑并删除网站',
websiteUnbindConfirm: '解绑并删除网站及目录?证书保留,重新绑定将新建网站。',
@@ -4,23 +4,22 @@
<span class="font-medium">{{ $t('panelMCP.websiteBinding') }}</span>
<el-tag
v-if="binding"
:type="binding.state === 'bound' ? 'success' : binding.state === 'unbound' ? 'info' : 'warning'"
:type="
pending
? 'warning'
: binding.state === 'bound'
? 'success'
: binding.state === 'unbound'
? 'info'
: 'warning'
"
size="small"
>
{{ $t(`panelMCP.${stateLabels[binding.state]}`) }}
{{ $t(`panelMCP.${stateLabels[pending ? 'pending' : binding.state]}`) }}
</el-tag>
</div>
<el-alert v-if="errorKey" class="!mb-4" type="warning" :closable="false" :title="$t(errorKey)" show-icon />
<el-alert
v-else-if="binding?.state === 'pending' || uncertain"
class="!mb-4"
type="warning"
:closable="false"
:title="$t('panelMCP.websiteUncertain')"
show-icon
/>
<el-alert
v-else-if="binding?.state === 'unavailable'"
v-if="binding?.state === 'unavailable'"
class="!mb-4"
type="warning"
:closable="false"
@@ -109,8 +108,6 @@
</el-button>
<el-button
v-if="pendingOperation?.operation === 'bind' || pendingOperation?.operation === 'update'"
type="danger"
plain
:disabled="disabled || !canManage || busy"
@click="cancelPending"
>
@@ -130,7 +127,7 @@
<el-button :disabled="disabled || !canManage || busy" @click="editCertificate">
{{ $t('panelMCP.websiteCertificateUpdate') }}
</el-button>
<el-button type="danger" plain :disabled="disabled || !canManage || busy" @click="unbind">
<el-button :disabled="disabled || !canManage || busy" @click="unbind">
{{ $t('panelMCP.websiteUnbind') }}
</el-button>
</template>
@@ -157,6 +154,7 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import { isAxiosError } from 'axios';
import { v4 as uuidv4 } from 'uuid';
import type { PanelMCP } from '@/api/interface/panel-mcp';
import {
@@ -166,18 +164,12 @@ import {
updatePanelMCPWebsite,
} from '@/api/modules/panel-mcp';
import { listLocalNodeSSL } from '@/api/modules/website';
import {
getMCPConflict,
getMCPWebsiteHostname,
isMCPMutationUncertain,
isMCPRevisionConflict,
isMCPWebsiteCertificateMatch,
} from '@/utils/panel-mcp';
import { getMCPWebsiteHostname, isMCPMutationUncertain, isMCPWebsiteCertificateMatch } from '@/utils/panel-mcp';
import { dateFormatSimple } from '@/utils/date';
import { listNodes } from '@/utils/node';
import { routerToNameWithParams } from '@/utils/router';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { MsgSuccess } from '@/utils/message';
import { MsgError, MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
const props = defineProps<{
@@ -193,7 +185,7 @@ const emit = defineEmits<{
}>();
const { globalStore } = useGlobalStore();
type Certificate = { id: number; primaryDomain: string; otherDomains: string; expireDate: string };
type PendingOperation = PanelMCP.WebsitePendingRequest;
type PendingOperation = PanelMCP.WebsitePendingRequest & { revision?: number };
const stateLabels: Record<PanelMCP.WebsiteBinding['state'], string> = {
unbound: 'websiteUnbound',
pending: 'websitePending',
@@ -208,13 +200,14 @@ const certificatesLoaded = ref(false);
const certificates = ref<Certificate[]>([]);
const editing = ref(false);
const uncertain = ref(false);
const errorKey = ref('');
const domainError = ref('');
const certificateError = ref('');
const localPending = ref<PendingOperation>();
const busy = computed(() => reading.value || writing.value);
const associated = computed(() => !!binding.value && binding.value.state !== 'unbound');
const pendingOperation = computed(() => localPending.value || binding.value?.pendingRequest);
const pendingOperation = computed<PendingOperation | undefined>(
() => localPending.value || binding.value?.pendingRequest,
);
const pending = computed(() => binding.value?.state === 'pending' || !!pendingOperation.value || uncertain.value);
const showEditor = computed(
() => (!associated.value && (props.mode === 'settings' || props.draft.requested)) || editing.value,
@@ -236,9 +229,37 @@ let generation = 0;
let readVersion = 0;
let navigationVersion = 0;
let certificateVersion = 0;
let certificatePromise: Promise<void> | undefined;
let certificatePromise: Promise<boolean> | undefined;
const current = (version: number) => active && props.canManage && globalStore.isLogin && version === generation;
const notifyFailure = (error: unknown, key: string) => {
const requestError = isAxiosError(error);
if (requestError && /timeout/i.test(error.message)) {
MsgError(i18n.global.t('commons.msg.requestTimeout'));
return;
}
const data = (requestError ? error.response?.data : error) as { code?: unknown; message?: unknown } | undefined;
const message =
data && (requestError || typeof data.code === 'number') && typeof data.message === 'string'
? data.message.trim()
: '';
MsgError(
message.toLowerCase().includes('operation not permitted')
? i18n.global.t('license.tamperHelper')
: message || i18n.global.t(key),
);
};
const completed = (value: PanelMCP.WebsiteBinding, operation: PendingOperation) =>
value.requestId === operation.requestId &&
!value.pendingRequest &&
value.state === (operation.operation === 'unbind' ? 'unbound' : 'bound') &&
(operation.operation !== 'unbind' || operation.deleteWebsite !== true || value.websiteDeleted === true);
const finish = (value: PanelMCP.WebsiteBinding) => {
localPending.value = undefined;
uncertain.value = editing.value = false;
props.draft.requested = false;
emit('changed', value);
};
const acceptBinding = (value: PanelMCP.WebsiteBinding, syncDraft = true) => {
binding.value = value;
if (syncDraft && value.pendingRequest) {
@@ -277,39 +298,62 @@ const validBinding = (value: unknown): value is PanelMCP.WebsiteBinding => {
};
const read = async (version: number, syncDraft = true) => {
const ticket = ++readVersion;
const operation = pendingOperation.value;
const response = await getPanelMCPWebsite();
if (!current(version) || ticket !== readVersion) return;
if (!validBinding(response.data)) throw new Error('Invalid MCP website status');
if (!validBinding(response?.data)) throw new Error('Invalid MCP website status');
acceptBinding(response.data, syncDraft);
if (operation && completed(response.data, operation)) finish(response.data);
return response.data;
};
const load = async (refreshCertificates = false) => {
if (!active || !props.canManage || busy.value || props.disabled) return;
const version = generation;
reading.value = true;
const request = read(version, !refreshCertificates);
const ticket = readVersion;
const activeRead = () => current(version) && ticket === readVersion;
try {
const requests: Promise<unknown>[] = [read(version, !refreshCertificates)];
if (refreshCertificates) requests.push(loadCertificates(true));
const [status] = await Promise.allSettled(requests);
if (current(version)) {
errorKey.value = status.status === 'fulfilled' ? '' : 'panelMCP.websiteStatusLoadFailed';
const status = request
.catch((error) => {
if (activeRead()) notifyFailure(error, 'panelMCP.websiteStatusLoadFailed');
throw error;
})
.finally(() => {
if (!refreshCertificates && activeRead()) reading.value = false;
});
const requests: Promise<unknown>[] = [status];
if (refreshCertificates || (props.draft.enableSSL && showEditor.value)) {
requests.push(loadCertificates(refreshCertificates, false));
}
const [result, certificate] = await Promise.allSettled(requests);
if (
activeRead() &&
result.status === 'fulfilled' &&
certificate?.status === 'fulfilled' &&
certificate.value === false &&
(refreshCertificates || (props.draft.enableSSL && showEditor.value))
) {
notifyFailure(undefined, 'panelMCP.websiteCertificatesLoadFailed');
}
} finally {
if (current(version)) reading.value = false;
if (activeRead()) reading.value = false;
}
};
const refresh = () => load(true);
const loadCertificates = async (force = false) => {
if (!active || !props.canManage) return;
const loadCertificates = async (force = false, notify = true): Promise<boolean> => {
if (!active || !props.canManage || !globalStore.isLogin) return false;
if (certificatePromise && !force) return certificatePromise;
if (certificatesLoaded.value && !force) return;
if (certificatesLoaded.value && !force) return true;
const version = generation;
const ticket = ++certificateVersion;
certificatesBusy.value = true;
certificateError.value = '';
const loading = (async () => {
try {
const response = await listLocalNodeSSL({});
if (!current(version) || ticket !== certificateVersion) return;
const response = await listLocalNodeSSL({}, { errorMessageMode: 'manual' });
if (!current(version) || ticket !== certificateVersion) return false;
if (!Array.isArray(response?.data)) throw new Error('Invalid MCP certificate list');
certificates.value = response.data.map((item) => ({
id: item.id,
primaryDomain: item.primaryDomain,
@@ -318,17 +362,23 @@ const loadCertificates = async (force = false) => {
}));
certificatesLoaded.value = true;
certificateError.value = '';
} catch {
return true;
} catch (error) {
if (current(version) && ticket === certificateVersion) {
certificateError.value = i18n.global.t('panelMCP.websiteCertificateRequired');
certificatesLoaded.value = false;
if (notify && props.draft.enableSSL && showEditor.value) {
notifyFailure(error, 'panelMCP.websiteCertificatesLoadFailed');
}
}
return false;
} finally {
if (ticket === certificateVersion) certificatesBusy.value = false;
}
})();
certificatePromise = loading;
await loading;
const loaded = await loading;
if (certificatePromise === loading) certificatePromise = undefined;
return loaded;
};
const validate = async () => {
domainError.value = certificateError.value = '';
@@ -339,7 +389,8 @@ const validate = async () => {
return false;
}
if (props.draft.enableSSL) {
await loadCertificates();
const version = generation;
if (!(await loadCertificates()) || !current(version)) return false;
if (!matchingCertificates.value.some((item) => item.id === props.draft.sslID)) {
certificateError.value = i18n.global.t('panelMCP.websiteCertificateRequired');
return false;
@@ -352,13 +403,16 @@ const perform = async (operation: PendingOperation): Promise<boolean> => {
const version = generation;
writing.value = true;
emit('busy', true);
errorKey.value = '';
localPending.value = { ...operation };
const deletesWebsite = operation.operation === 'unbind' && operation.deleteWebsite === true;
try {
const latest = binding.value || (await read(version));
if (!current(version) || !latest) return false;
const request = { requestId: operation.requestId, revision: latest.revision };
// A retry must keep the revision that authorized the original intent.
const revision = operation.revision ?? latest.revision;
operation = { ...operation, revision };
localPending.value = operation;
const request = { requestId: operation.requestId, revision };
const response =
operation.operation === 'bind'
? await bindPanelMCPWebsite({
@@ -375,54 +429,30 @@ const perform = async (operation: PendingOperation): Promise<boolean> => {
})
: await unbindPanelMCPWebsite({
...request,
...(deletesWebsite ? { deleteWebsite: true } : {}),
...(operation.deleteWebsite !== undefined ? { deleteWebsite: operation.deleteWebsite } : {}),
});
if (!current(version)) return false;
if (!validBinding(response.data)) throw { code: 503 };
if (!validBinding(response?.data) || response.data.requestId !== operation.requestId) throw { code: 503 };
if (deletesWebsite && response.data.websiteDeleted !== true) throw { code: 503 };
acceptBinding(response.data);
if (response.data.state !== (operation.operation === 'unbind' ? 'unbound' : 'bound')) throw { code: 503 };
localPending.value = undefined;
uncertain.value = false;
editing.value = false;
props.draft.requested = false;
emit('changed', response.data);
if (!completed(response.data, operation)) throw { code: 503 };
finish(response.data);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
return true;
} catch (error) {
if (current(version)) {
uncertain.value = isMCPMutationUncertain(error);
errorKey.value = isMCPRevisionConflict(error)
? 'panelMCP.websiteConflict'
: uncertain.value
? 'panelMCP.websiteUncertain'
: 'panelMCP.websiteOperationFailed';
const conflict = getMCPConflict(error);
if (conflict?.reason === 'website_domain_conflict' && typeof conflict.message === 'string') {
domainError.value = conflict.message;
errorKey.value = '';
}
const unconfirmed = isMCPMutationUncertain(error);
uncertain.value = unconfirmed;
if (!uncertain.value) localPending.value = undefined;
try {
const verified = await read(version);
if (
uncertain.value &&
verified &&
verified.requestId === operation.requestId &&
!verified.pendingRequest &&
(!deletesWebsite || verified.websiteDeleted === true) &&
verified.state === (operation.operation === 'unbind' ? 'unbound' : 'bound')
) {
localPending.value = undefined;
uncertain.value = editing.value = false;
errorKey.value = '';
props.draft.requested = false;
emit('changed', verified);
return true;
}
if (unconfirmed && verified && completed(verified, operation)) return true;
} catch {
/* Keep the submitted operation until the user can check its result. */
}
if (current(version)) {
notifyFailure(error, unconfirmed ? 'panelMCP.websiteUncertain' : 'panelMCP.websiteOperationFailed');
}
}
return false;
} finally {
@@ -470,7 +500,7 @@ const requestUnbind = async (cancelPending: boolean) => {
await ElMessageBox.confirm(
i18n.global.t('panelMCP.websiteUnbindConfirm'),
i18n.global.t('panelMCP.websiteUnbind'),
{ type: 'warning', confirmButtonType: 'danger' },
{ type: 'warning' },
);
} catch {
return;
@@ -502,7 +532,6 @@ const activate = () => {
if (active) return;
active = true;
if (props.canManage) void load();
if (props.draft.enableSSL && showEditor.value) void loadCertificates();
};
const invalidate = () => {
generation++;
@@ -516,7 +545,7 @@ const invalidate = () => {
reading.value = false;
editing.value = uncertain.value = false;
localPending.value = undefined;
domainError.value = certificateError.value = errorKey.value = '';
domainError.value = certificateError.value = '';
emit('binding', undefined);
};
const deactivate = () => {