Dev User 1 ماه پیش
والد
کامیت
335ed435b7
3فایلهای تغییر یافته به همراه138 افزوده شده و 67 حذف شده
  1. 6 0
      data/www/assets/app.css
  2. 104 39
      data/www/assets/app.js
  3. 28 28
      data/www/index.html

+ 6 - 0
data/www/assets/app.css

@@ -594,6 +594,12 @@ html, body {
   cursor: pointer;
 }
 .sites-link:hover { text-decoration: underline; }
+.site-ssl-pem {
+  font-size: 0.78rem;
+  line-height: 1.4;
+  resize: vertical;
+  min-height: 140px;
+}
 .site-ssl-domain-list {
   max-height: 180px;
   overflow: auto;

+ 104 - 39
data/www/assets/app.js

@@ -1124,6 +1124,28 @@
         ) {
           softChanged = true;
         }
+        if (
+          prev &&
+          (prev === "running" || prev === "pending") &&
+          now === "success" &&
+          (t.type || "") === "ssl.letsencrypt"
+        ) {
+          softChanged = true;
+          if (state.siteSettings && state.siteSettingsModal) {
+            const name = state.siteSettings.name;
+            refresh().then(() => {
+              const latest = state.sites.find((s) => s.name === name);
+              if (latest) {
+                Object.assign(state.siteSettings, latest);
+                fillSiteSettingsForm(state.siteSettings);
+                $("#site-set-ssl-enable").checked = true;
+                setSiteCertSubTab("pem");
+                loadSiteSslPem().catch(() => {});
+                toast("证书已申请并写入本站 ssl/");
+              }
+            }).catch(() => {});
+          }
+        }
         state.taskStatusMap[t.id] = now;
       }
       state.tasks = tasks;
@@ -1549,11 +1571,62 @@
   function setSiteSettingsTab(tab) {
     $$(".site-settings-tab").forEach((b) => b.classList.toggle("is-active", b.dataset.siteTab === tab));
     $$(".site-settings-panel").forEach((p) => p.classList.toggle("is-active", p.dataset.sitePanel === tab));
-    if (tab === "cert") renderSslDomainChecklist(selectedSslHosts());
+    if (tab === "cert") {
+      setSiteCertSubTab(state.siteCertTab || "pem");
+      loadSiteSslPem().catch(() => {});
+      renderSslDomainChecklist(selectedSslHosts());
+    }
     if (tab === "nginx") loadSiteNginxConf().catch((err) => toast(err.message || String(err), "err"));
     if (tab === "logs") loadSiteLog().catch((err) => toast(err.message || String(err), "err"));
   }
 
+  function setSiteCertSubTab(tab) {
+    const which = tab === "apply" ? "apply" : "pem";
+    state.siteCertTab = which;
+    $("#site-cert-tab-pem")?.classList.toggle("active", which === "pem");
+    $("#site-cert-tab-apply")?.classList.toggle("active", which === "apply");
+    const pem = $("#site-cert-pane-pem");
+    const apply = $("#site-cert-pane-apply");
+    if (pem) pem.hidden = which !== "pem";
+    if (apply) apply.hidden = which !== "apply";
+    if (which === "apply") renderSslDomainChecklist(selectedSslHosts());
+  }
+
+  function siteSslPaths(site) {
+    const sslDir = joinPath(site.root || "", "ssl");
+    return {
+      dir: sslDir,
+      cert: joinPath(sslDir, "fullchain.pem"),
+      key: joinPath(sslDir, "privkey.pem"),
+      readCert: site.ssl_cert || joinPath(sslDir, "fullchain.pem"),
+      readKey: site.ssl_key || joinPath(sslDir, "privkey.pem"),
+    };
+  }
+
+  async function readFileText(path) {
+    if (!path) return "";
+    try {
+      const data = await api(`/api/files/read?path=${encodeURIComponent(path)}`);
+      return (data && data.content) || "";
+    } catch (_) {
+      return "";
+    }
+  }
+
+  async function loadSiteSslPem() {
+    const site = state.siteSettings;
+    if (!site || !site.root) return;
+    const paths = siteSslPaths(site);
+    const [pem, key] = await Promise.all([
+      readFileText(paths.readCert),
+      readFileText(paths.readKey),
+    ]);
+    const pemEl = $("#site-set-ssl-pem");
+    const keyEl = $("#site-set-ssl-key-pem");
+    if (pemEl) pemEl.value = pem;
+    if (keyEl) keyEl.value = key;
+  }
+
   function domainsFromText(text) {
     return String(text || "")
       .split(/[\s,;]+/)
@@ -1635,8 +1708,6 @@
     $("#site-set-domain").value = domains.join("\n");
     $("#site-set-ssl-enable").checked = !!site.ssl_enable;
     $("#site-set-ssl-port").value = site.ssl_port || 443;
-    $("#site-set-ssl-cert").value = site.ssl_cert || "";
-    $("#site-set-ssl-key").value = site.ssl_key || "";
     $("#site-set-conf-path").textContent = site.conf || "";
     $("#site-set-nginx-conf").value = "";
     state.siteLogKind = "access";
@@ -1650,6 +1721,7 @@
     const site = state.sites.find((s) => s.name === name);
     if (!site) throw new Error("未找到网站");
     fillSiteSettingsForm(site);
+    state.siteCertTab = "pem";
     setSiteSettingsTab("domain");
     state.siteSettingsModal.show();
   }
@@ -1775,12 +1847,29 @@
 
   async function saveSiteCert() {
     const site = state.siteSettings;
-    if (!site) return;
+    if (!site || !site.root) return;
     const domains = domainsFromText($("#site-set-domain").value);
     const ssl_enable = $("#site-set-ssl-enable").checked;
     const ssl_port = Number($("#site-set-ssl-port").value || 443);
-    const ssl_cert = $("#site-set-ssl-cert").value.trim();
-    const ssl_key = $("#site-set-ssl-key").value.trim();
+    const pem = ($("#site-set-ssl-pem")?.value || "").trim();
+    const key = ($("#site-set-ssl-key-pem")?.value || "").trim();
+    if (ssl_enable && (!pem || !key)) {
+      throw new Error("启用 HTTPS 时请填写证书 PEM 与私钥 KEY");
+    }
+    const paths = siteSslPaths(site);
+    await api("/api/files/mkdir", { method: "POST", body: { path: paths.dir } }).catch(() => {});
+    if (pem) {
+      await api("/api/files/write", {
+        method: "POST",
+        body: { path: paths.cert, content: pem.endsWith("\n") ? pem : pem + "\n" },
+      });
+    }
+    if (key) {
+      await api("/api/files/write", {
+        method: "POST",
+        body: { path: paths.key, content: key.endsWith("\n") ? key : key + "\n" },
+      });
+    }
     const updated = await api("/api/websites/update", {
       method: "POST",
       body: {
@@ -1788,13 +1877,14 @@
         domains: domains.length ? domains : domainsFromText(site.domain),
         ssl_enable,
         ssl_port,
-        ssl_cert,
-        ssl_key,
+        ssl_cert: paths.cert,
+        ssl_key: paths.key,
       },
     });
     Object.assign(site, updated || {});
     fillSiteSettingsForm(site);
-    toast("证书设置已保存");
+    await loadSiteSslPem();
+    toast("证书已保存并重载");
     await refresh();
   }
 
@@ -1817,21 +1907,6 @@
     }
   }
 
-  async function uploadSiteSslFile(kind, file) {
-    const site = state.siteSettings;
-    if (!site || !site.root) throw new Error("网站目录未知");
-    const sslDir = joinPath(site.root, "ssl");
-    await api("/api/files/mkdir", { method: "POST", body: { path: sslDir } }).catch(() => {});
-    const dest = joinPath(sslDir, file.name);
-    await api("/api/files/upload", {
-      method: "POST",
-      body: { path: dest, content_base64: await fileToBase64(file) },
-    });
-    if (kind === "cert") $("#site-set-ssl-cert").value = dest;
-    else $("#site-set-ssl-key").value = dest;
-    toast("证书文件已上传");
-  }
-
   function destroyAnalyticsCharts() {
     (state.analyticsCharts || []).forEach((c) => {
       try { c.destroy(); } catch (_) {}
@@ -2142,6 +2217,11 @@
     $("#site-set-cert-save").addEventListener("click", (e) => {
       withBusy(async () => saveSiteCert(), e.currentTarget);
     });
+    $("#site-cert-tab-pem").addEventListener("click", () => {
+      setSiteCertSubTab("pem");
+      loadSiteSslPem().catch(() => {});
+    });
+    $("#site-cert-tab-apply").addEventListener("click", () => setSiteCertSubTab("apply"));
     $("#site-set-ssl-apply").addEventListener("click", (e) => {
       withBusy(async () => applyLetsEncrypt(), e.currentTarget);
     });
@@ -2181,21 +2261,6 @@
       state.filesPath = site.log_dir;
       setView("files");
     });
-    $("#site-set-ssl-cert-upload").addEventListener("click", () => {
-      state.siteSslUploadTarget = "cert";
-      $("#site-ssl-file-input").click();
-    });
-    $("#site-set-ssl-key-upload").addEventListener("click", () => {
-      state.siteSslUploadTarget = "key";
-      $("#site-ssl-file-input").click();
-    });
-    $("#site-ssl-file-input").addEventListener("change", async (e) => {
-      const file = e.target.files && e.target.files[0];
-      const kind = state.siteSslUploadTarget;
-      e.target.value = "";
-      if (!file || !kind) return;
-      await withBusy(async () => uploadSiteSslFile(kind, file));
-    });
   }
 
   function bindEvents() {

+ 28 - 28
data/www/index.html

@@ -288,10 +288,35 @@
                 <button type="button" class="btn btn-sm btn-primary" id="site-set-domain-save">保存并重载</button>
               </div>
               <div class="site-settings-panel" data-site-panel="cert">
-                <h6 class="mb-3">SSL 证书</h6>
-                <div class="border rounded p-3 mb-3 bg-light">
+                <div class="btn-group btn-group-sm mb-3" role="group" aria-label="证书子页">
+                  <button type="button" class="btn btn-outline-primary active" id="site-cert-tab-pem" data-cert-tab="pem">证书内容</button>
+                  <button type="button" class="btn btn-outline-primary" id="site-cert-tab-apply" data-cert-tab="apply">申请 SSL</button>
+                </div>
+
+                <div id="site-cert-pane-pem">
+                  <div class="form-check form-switch mb-3">
+                    <input class="form-check-input" type="checkbox" id="site-set-ssl-enable" />
+                    <label class="form-check-label" for="site-set-ssl-enable">启用 HTTPS</label>
+                  </div>
+                  <div class="mb-3">
+                    <label class="form-label">HTTPS 端口</label>
+                    <input class="form-control form-control-sm" id="site-set-ssl-port" type="number" min="1" max="65535" value="443" />
+                  </div>
+                  <div class="mb-3">
+                    <label class="form-label">证书 PEM <span class="text-secondary fw-normal">(fullchain)</span></label>
+                    <textarea class="form-control font-monospace site-ssl-pem" id="site-set-ssl-pem" rows="8" spellcheck="false" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"></textarea>
+                  </div>
+                  <div class="mb-3">
+                    <label class="form-label">私钥 KEY</label>
+                    <textarea class="form-control font-monospace site-ssl-pem" id="site-set-ssl-key-pem" rows="8" spellcheck="false" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"></textarea>
+                  </div>
+                  <p class="small text-secondary mb-3">保存后写入网站目录 <code>ssl/fullchain.pem</code> 与 <code>ssl/privkey.pem</code> 并重载 Nginx。</p>
+                  <button type="button" class="btn btn-sm btn-primary" id="site-set-cert-save">保存并重载</button>
+                </div>
+
+                <div id="site-cert-pane-apply" hidden>
                   <div class="fw-semibold mb-2">Let's Encrypt 免费证书</div>
-                  <p class="small text-secondary mb-2">勾选要申请的域名,将合并签发到<strong>一张</strong>证书。域名需已解析到本机,且公网可访问 80 端口。</p>
+                  <p class="small text-secondary mb-2">勾选域名后合并签发到一张证书,成功后自动写入本站 <code>ssl/</code> 并启用 HTTPS。域名需已解析到本机,且公网可访问 80 端口。</p>
                   <div class="mb-2">
                     <div class="d-flex justify-content-between align-items-center mb-1">
                       <label class="form-label mb-0">申请域名</label>
@@ -304,31 +329,6 @@
                   </div>
                   <button type="button" class="btn btn-sm btn-success" id="site-set-ssl-apply">申请 / 续签证书</button>
                 </div>
-                <div class="form-check form-switch mb-3">
-                  <input class="form-check-input" type="checkbox" id="site-set-ssl-enable" />
-                  <label class="form-check-label" for="site-set-ssl-enable">启用 HTTPS</label>
-                </div>
-                <div class="mb-3">
-                  <label class="form-label">HTTPS 端口</label>
-                  <input class="form-control form-control-sm" id="site-set-ssl-port" type="number" min="1" max="65535" value="443" />
-                </div>
-                <div class="mb-3">
-                  <label class="form-label">证书文件 (.crt / .pem)</label>
-                  <div class="input-group input-group-sm">
-                    <input class="form-control" id="site-set-ssl-cert" placeholder="/path/to/fullchain.pem" />
-                    <button type="button" class="btn btn-outline-secondary" id="site-set-ssl-cert-upload">上传</button>
-                  </div>
-                </div>
-                <div class="mb-3">
-                  <label class="form-label">私钥文件 (.key)</label>
-                  <div class="input-group input-group-sm">
-                    <input class="form-control" id="site-set-ssl-key" placeholder="/path/to/privkey.pem" />
-                    <button type="button" class="btn btn-outline-secondary" id="site-set-ssl-key-upload">上传</button>
-                  </div>
-                </div>
-                <input type="file" id="site-ssl-file-input" hidden accept=".pem,.crt,.cer,.key" />
-                <p class="small text-secondary mb-3">也可手动上传证书到网站目录 <code>ssl/</code> 后保存启用。</p>
-                <button type="button" class="btn btn-sm btn-primary" id="site-set-cert-save">保存并重载</button>
               </div>
               <div class="site-settings-panel" data-site-panel="nginx">
                 <div class="d-flex justify-content-between align-items-center mb-2">