Protect CMS deploy password field

This commit is contained in:
DCCONSTRUCTIONS 2026-07-06 13:22:44 +03:00
parent dc49acaa43
commit cfee9d0c19
3 changed files with 92 additions and 4 deletions

View File

@ -970,6 +970,46 @@ body:not([data-admin-mode="knowledge"]) #knowledge-form {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.secret-input-wrap {
position: relative;
display: grid;
min-width: 0;
}
.secret-input-wrap input {
padding-right: 3rem;
}
.secret-toggle-button {
position: absolute;
top: 50%;
right: 0.42rem;
display: grid;
width: 2.1rem;
height: 2.1rem;
place-items: center;
border: 0;
border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.065);
color: rgba(255, 255, 255, 0.72);
transform: translateY(-50%);
}
.secret-toggle-button:hover {
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
}
.secret-toggle-button svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.field-label-row { .field-label-row {
display: flex; display: flex;
min-width: 0; min-width: 0;

View File

@ -640,6 +640,14 @@ function syncProjectConfigFromForm() {
async function saveProjectSettings() { async function saveProjectSettings() {
syncProjectConfigFromForm(); syncProjectConfigFromForm();
const passwordEnv = String(state.projectConfig?.deploy?.passwordEnv || "").trim();
if (passwordEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(passwordEnv)) {
setByPath(state.projectConfig, "deploy.passwordEnv", "MCHOST_PASSWORD");
renderProjectSettings();
setStatus("FTP-пароль не сохраняем в модель. Поле сброшено к имени переменной MCHOST_PASSWORD.");
return;
}
const response = await fetch("/api/project/config", { const response = await fetch("/api/project/config", {
method: "PUT", method: "PUT",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@ -1932,6 +1940,7 @@ function createProjectField({ path, label, type = "text", placeholder = "", opti
const badge = document.createElement("span"); const badge = document.createElement("span");
const control = options.length ? document.createElement("select") : document.createElement("input"); const control = options.length ? document.createElement("select") : document.createElement("input");
const value = projectConfigValue(path); const value = projectConfigValue(path);
const isSecret = type === "password";
field.className = type === "checkbox" ? "toggle-row" : "field-control"; field.className = type === "checkbox" ? "toggle-row" : "field-control";
title.textContent = label; title.textContent = label;
@ -1956,8 +1965,30 @@ function createProjectField({ path, label, type = "text", placeholder = "", opti
} }
badge.className = "field-kind"; badge.className = "field-kind";
badge.textContent = options.length ? "SELECT" : type === "number" ? "NUMBER" : "TEXT"; badge.textContent = options.length ? "SELECT" : type === "number" ? "NUMBER" : isSecret ? "SECRET" : "TEXT";
field.append(title, badge, control); field.append(title, badge);
if (isSecret) {
const secretWrap = document.createElement("span");
const reveal = document.createElement("button");
secretWrap.className = "secret-input-wrap";
reveal.className = "secret-toggle-button";
reveal.type = "button";
reveal.innerHTML =
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.75 12s3.25-5.5 9.25-5.5 9.25 5.5 9.25 5.5-3.25 5.5-9.25 5.5S2.75 12 2.75 12Z"></path><path d="M12 9.25a2.75 2.75 0 1 1 0 5.5 2.75 2.75 0 0 1 0-5.5Z"></path></svg>';
reveal.setAttribute("aria-label", "Показать значение");
reveal.title = "Показать значение";
reveal.addEventListener("click", () => {
const visible = control.type === "text";
control.type = visible ? "password" : "text";
reveal.setAttribute("aria-label", visible ? "Показать значение" : "Скрыть значение");
reveal.title = visible ? "Показать значение" : "Скрыть значение";
});
secretWrap.append(control, reveal);
field.append(secretWrap);
} else {
field.append(control);
}
} }
control.dataset.projectPath = path; control.dataset.projectPath = path;
@ -2113,7 +2144,13 @@ function renderProjectSettings() {
createProjectField({ path: "deploy.host", label: "FTP / SFTP host", placeholder: "a0000.ftp.mchost.ru" }), createProjectField({ path: "deploy.host", label: "FTP / SFTP host", placeholder: "a0000.ftp.mchost.ru" }),
createProjectField({ path: "deploy.port", label: "Порт", type: "number", placeholder: "22" }), createProjectField({ path: "deploy.port", label: "Порт", type: "number", placeholder: "22" }),
createProjectField({ path: "deploy.username", label: "Логин" }), createProjectField({ path: "deploy.username", label: "Логин" }),
createProjectField({ path: "deploy.passwordEnv", label: "Переменная пароля", placeholder: "MCHOST_PASSWORD" }), createProjectField({
path: "deploy.passwordEnv",
label: "Имя переменной пароля",
type: "password",
placeholder: "MCHOST_PASSWORD",
hint: "Не вводите сюда сам FTP-пароль. Укажите имя переменной окружения сервиса, например MCHOST_PASSWORD.",
}),
createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }), createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }),
], ],
}); });

View File

@ -215,6 +215,11 @@ function trailingSlashUrl(value) {
return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; return trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
} }
function normalizeSecretEnvName(value, fallback = "MCHOST_PASSWORD") {
const trimmed = String(value || "").trim();
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed) ? trimmed : fallback;
}
function fileReady(filePath) { function fileReady(filePath) {
try { try {
return statSync(filePath).isFile(); return statSync(filePath).isFile();
@ -809,8 +814,14 @@ function authorizeRequest(req, res, url) {
} }
function publicProjectConfig() { function publicProjectConfig() {
const deploy = {
...(projectConfig.deploy || {}),
passwordEnv: normalizeSecretEnvName(projectConfig.deploy?.passwordEnv),
};
return { return {
...projectConfig, ...projectConfig,
deploy,
id: projectConfig.id || activeProjectId, id: projectConfig.id || activeProjectId,
runtime: { runtime: {
cmsRoot, cmsRoot,
@ -829,7 +840,7 @@ function normalizeProjectConfig(payload) {
host: String(payload?.deploy?.host || "").trim(), host: String(payload?.deploy?.host || "").trim(),
port: Number(payload?.deploy?.port || (payload?.deploy?.method === "ftp" ? 21 : 22)), port: Number(payload?.deploy?.port || (payload?.deploy?.method === "ftp" ? 21 : 22)),
username: String(payload?.deploy?.username || "").trim(), username: String(payload?.deploy?.username || "").trim(),
passwordEnv: String(payload?.deploy?.passwordEnv || "").trim(), passwordEnv: normalizeSecretEnvName(payload?.deploy?.passwordEnv),
remotePath: String(payload?.deploy?.remotePath || "").trim(), remotePath: String(payload?.deploy?.remotePath || "").trim(),
deployOnRender: Boolean(payload?.deploy?.deployOnRender), deployOnRender: Boolean(payload?.deploy?.deployOnRender),
}; };