mirror of
https://github.com/andrey271192/amnezia_web.git
synced 2026-09-20 14:41:59 +00:00
feat(community): optional PRO install via GitHub token in UI
- POST /api/community/run-private-install fetches private install.sh and runs bash detached (202 Accepted) - Gated by ALLOW_COMMUNITY_GITHUB_ACTIVATION + AMNEZIA_EDITION=community + admin session - Log appended to /data/community-install-last.log; install bash bundled in Dockerfile Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
RUN apk add --no-cache docker-cli openssh-client sshpass
|
RUN apk add --no-cache docker-cli bash openssh-client sshpass
|
||||||
|
|
||||||
RUN mkdir -p /data && chmod 700 /data
|
RUN mkdir -p /data && chmod 700 /data
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
Редакция **`community`** задаётся автоматически файлом **`.amnezia-panel-edition`** в корне репозитория (`community`) или переменной окружения **`AMNEZIA_EDITION=community`** в контейнере. Кнопка и текст про подписку настраиваются **`COMMUNITY_UPGRADE_URL`** и **`COMMUNITY_UPGRADE_PITCH`**.
|
Редакция **`community`** задаётся автоматически файлом **`.amnezia-panel-edition`** в корне репозитория (`community`) или переменной окружения **`AMNEZIA_EDITION=community`** в контейнере. Кнопка и текст про подписку настраиваются **`COMMUNITY_UPGRADE_URL`** и **`COMMUNITY_UPGRADE_PITCH`**.
|
||||||
|
|
||||||
**Безопасность:** доступ к Docker-сокету в контейнере панели эквивалентен root на хосте — используйте сложный пароль и ограничьте доступ по IP / TLS.
|
**Безопасность:** доступ к Docker-сокету в контейнере панели эквивалентен root на хосте — используйте сложный пароль и ограничьте доступ по IP / TLS. Поле установки PRO по GitHub‑токену (`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`) отключено по умолчанию: при включении любой авторизованный администратор может запускать произвольный `install.sh` на хосте через Docker‑сокет.
|
||||||
|
|
||||||
Справочник по типичным сбоям и API (в т.ч. для PRO): в полной документации репозитория PRO.
|
Справочник по типичным сбоям и API (в т.ч. для PRO): в полной документации репозитория PRO.
|
||||||
|
|
||||||
@@ -44,6 +44,10 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
|||||||
| `COMMUNITY_UPGRADE_URL` | Страница подписки PRO на Boosty (зашита в код по умолчанию) | Переопределите, если смените уровень подписки |
|
| `COMMUNITY_UPGRADE_URL` | Страница подписки PRO на Boosty (зашита в код по умолчанию) | Переопределите, если смените уровень подписки |
|
||||||
| `COMMUNITY_UPGRADE_PITCH` | _(текст по умолчанию в коде)_ | Текст под заголовком базовой версии |
|
| `COMMUNITY_UPGRADE_PITCH` | _(текст по умолчанию в коде)_ | Текст под заголовком базовой версии |
|
||||||
| `SKIP_LANDING` | `0` | `1` — без лендинга на порту 80 |
|
| `SKIP_LANDING` | `0` | `1` — без лендинга на порту 80 |
|
||||||
|
| `ALLOW_COMMUNITY_GITHUB_ACTIVATION` | `0` | `1` — в редакции community показывать ввод GitHub-токена и запускать приватный `install.sh` (доступ к репо с Contents Read / repo; **высокая чувствительность**, см. раздел безопасности) |
|
||||||
|
| `COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL` | `https://raw.githubusercontent.com/andrey271192/amnezia_web-pro/main/scripts/install.sh` | Сырой URL `scripts/install.sh` в **вашем** приватном репозитории PRO |
|
||||||
|
| `COMMUNITY_INSTALL_FETCH_MS` | `60000` | Таймаут HTTP при скачивании установщика из GitHub |
|
||||||
|
| `PRIVATE_INSTALL_SCRIPT_MAX_BYTES` | `2097152` | Максимальный размер скачанного скрипта (байты) |
|
||||||
|
|
||||||
Остальные переменные совместимы с образом панели (см. Dockerfile / `server.js` в этом репозитории).
|
Остальные переменные совместимы с образом панели (см. Dockerfile / `server.js` в этом репозитории).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amnezia-admin",
|
"name": "amnezia-admin",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "amnezia_web — базовая панель AmneziaWG (только просмотр клиентов; полная версия — PRO).",
|
"description": "amnezia_web — базовая панель AmneziaWG (только просмотр клиентов; полная версия — PRO).",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ let editionState = {
|
|||||||
upgradeUrl: null,
|
upgradeUrl: null,
|
||||||
upgradePitch: null,
|
upgradePitch: null,
|
||||||
showDebugWg: true,
|
showDebugWg: true,
|
||||||
|
githubActivationAllowed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyEditionPayload(data) {
|
function applyEditionPayload(data) {
|
||||||
@@ -81,6 +82,7 @@ function applyEditionPayload(data) {
|
|||||||
upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null,
|
upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null,
|
||||||
upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null,
|
upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null,
|
||||||
showDebugWg: ed.showDebugWg !== false,
|
showDebugWg: ed.showDebugWg !== false,
|
||||||
|
githubActivationAllowed: Boolean(ed.githubActivationAllowed),
|
||||||
};
|
};
|
||||||
const titleEl = document.querySelector(".top h1");
|
const titleEl = document.querySelector(".top h1");
|
||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
@@ -122,6 +124,75 @@ function applyEditionPayload(data) {
|
|||||||
cta.textContent = "Разблокировать PRO (Boosty)";
|
cta.textContent = "Разблокировать PRO (Boosty)";
|
||||||
wrap.append(textCol, cta);
|
wrap.append(textCol, cta);
|
||||||
editionBanner.appendChild(wrap);
|
editionBanner.appendChild(wrap);
|
||||||
|
|
||||||
|
if (editionState.githubActivationAllowed) {
|
||||||
|
const act = document.createElement("div");
|
||||||
|
act.className = "edition-banner-activation muted";
|
||||||
|
const cap = document.createElement("div");
|
||||||
|
cap.className = "edition-banner-act-title";
|
||||||
|
cap.textContent =
|
||||||
|
"Подписка получена и есть GitHub‑токен к приватному репозиторию? Запуск установки PRO с сервера (порт обычно сохранится; процесс см. журнал):";
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "edition-banner-act-row";
|
||||||
|
|
||||||
|
const inp = document.createElement("input");
|
||||||
|
inp.type = "password";
|
||||||
|
inp.autocomplete = "new-password";
|
||||||
|
inp.spellcheck = false;
|
||||||
|
inp.placeholder = "Токен (classic: repo или fine‑grained: Contents Read)";
|
||||||
|
inp.className = "edition-banner-act-input monospace";
|
||||||
|
|
||||||
|
const go = document.createElement("button");
|
||||||
|
go.type = "button";
|
||||||
|
go.className = "btn small primary";
|
||||||
|
go.textContent = "Установить PRO";
|
||||||
|
|
||||||
|
const msg = document.createElement("p");
|
||||||
|
msg.className = "edition-banner-act-msg muted";
|
||||||
|
msg.setAttribute("role", "status");
|
||||||
|
msg.textContent = "";
|
||||||
|
|
||||||
|
go.addEventListener("click", async () => {
|
||||||
|
msg.textContent = "";
|
||||||
|
msg.className = "edition-banner-act-msg muted";
|
||||||
|
const tok = inp.value.trim();
|
||||||
|
if (!tok) {
|
||||||
|
msg.className = "edition-banner-act-msg status err";
|
||||||
|
msg.textContent = "Вставьте токен.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
go.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/community/run-private-install", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ githubToken: tok }),
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) {
|
||||||
|
msg.className = "edition-banner-act-msg status err";
|
||||||
|
msg.textContent = j.error || `Ошибка ${r.status}`;
|
||||||
|
go.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msg.className = "edition-banner-act-msg edition-act-ok muted";
|
||||||
|
msg.textContent =
|
||||||
|
j.message ||
|
||||||
|
"Установка запущена. Через 2–5 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу).";
|
||||||
|
inp.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
msg.className = "edition-banner-act-msg status err";
|
||||||
|
msg.textContent = String(e?.message || e);
|
||||||
|
go.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(inp, go);
|
||||||
|
act.append(cap, row, msg);
|
||||||
|
editionBanner.appendChild(act);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
editionBanner.classList.add("hidden");
|
editionBanner.classList.add("hidden");
|
||||||
editionBanner.innerHTML = "";
|
editionBanner.innerHTML = "";
|
||||||
|
|||||||
@@ -950,3 +950,48 @@ tr:last-child td {
|
|||||||
.edition-banner-cta {
|
.edition-banner-cta {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edition-banner-activation {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px dashed rgba(125, 211, 252, 0.35);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edition-banner-act-title {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edition-banner-act-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edition-banner-act-input {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edition-banner-act-input.monospace {
|
||||||
|
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edition-banner-act-msg {
|
||||||
|
margin: 0.55rem 0 0 !important;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edition-act-ok {
|
||||||
|
color: #a7f3d0 !important;
|
||||||
|
}
|
||||||
|
|||||||
158
server.js
158
server.js
@@ -40,6 +40,13 @@ const UI_HIDDEN = resolveUiHidden();
|
|||||||
|
|
||||||
const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase();
|
const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase();
|
||||||
const IS_COMMUNITY = AMNEZIA_EDITION === "community";
|
const IS_COMMUNITY = AMNEZIA_EDITION === "community";
|
||||||
|
const ALLOW_COMMUNITY_GITHUB_ACTIVATION = envTruthy(process.env.ALLOW_COMMUNITY_GITHUB_ACTIVATION);
|
||||||
|
const COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL =
|
||||||
|
process.env.COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL?.trim() ||
|
||||||
|
"https://raw.githubusercontent.com/andrey271192/amnezia_web-pro/main/scripts/install.sh";
|
||||||
|
const PRIVATE_INSTALL_SCRIPT_MAX_BYTES = Number(process.env.PRIVATE_INSTALL_SCRIPT_MAX_BYTES || 2_097_152) || 2_097_152;
|
||||||
|
/** Флаг выполнения одноразового bash install из UI (держим второй параллельный запрос). */
|
||||||
|
let communityPrivateInstallBusy = false;
|
||||||
const COMMUNITY_UPGRADE_URL =
|
const COMMUNITY_UPGRADE_URL =
|
||||||
process.env.COMMUNITY_UPGRADE_URL?.trim() ||
|
process.env.COMMUNITY_UPGRADE_URL?.trim() ||
|
||||||
"https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link";
|
"https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link";
|
||||||
@@ -54,6 +61,7 @@ function editionPayload() {
|
|||||||
upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null,
|
upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null,
|
||||||
upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null,
|
upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null,
|
||||||
showDebugWg: !IS_COMMUNITY,
|
showDebugWg: !IS_COMMUNITY,
|
||||||
|
githubActivationAllowed: ALLOW_COMMUNITY_GITHUB_ACTIVATION && IS_COMMUNITY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +357,23 @@ function rejectCommunityProOnly(res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Токен GitHub только из печатаемых ASCII без пробелов/newline (classic ghp_* / github_pat_*). */
|
||||||
|
function validateGithubBearerToken(tok) {
|
||||||
|
if (typeof tok !== "string") return false;
|
||||||
|
const s = tok.trim();
|
||||||
|
if (s.length < 20 || s.length > 4096) return false;
|
||||||
|
return /^[!-~]+$/.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateInstallScriptUrlLogged() {
|
||||||
|
try {
|
||||||
|
const u = new URL(COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL);
|
||||||
|
return `${u.hostname}${u.pathname}`;
|
||||||
|
} catch {
|
||||||
|
return "(invalid PRIVATE_INSTALL_SCRIPT_URL)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function requireProTier(_req, res, next) {
|
function requireProTier(_req, res, next) {
|
||||||
if (!IS_COMMUNITY) {
|
if (!IS_COMMUNITY) {
|
||||||
next();
|
next();
|
||||||
@@ -1365,8 +1390,141 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade) {
|
|||||||
if (IS_COMMUNITY) {
|
if (IS_COMMUNITY) {
|
||||||
console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`);
|
console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`);
|
||||||
}
|
}
|
||||||
|
if (ALLOW_COMMUNITY_GITHUB_ACTIVATION && IS_COMMUNITY) {
|
||||||
|
console.warn(
|
||||||
|
"Разрешена установка PRO из UI (ALLOW_COMMUNITY_GITHUB_ACTIVATION): GitHub-токен не сохранять в журналах; защитите доступ к паролю панели и Docker-сокету.",
|
||||||
|
);
|
||||||
|
}
|
||||||
app.use(express.json({ limit: "512kb" }));
|
app.use(express.json({ limit: "512kb" }));
|
||||||
|
|
||||||
|
app.post("/api/community/run-private-install", requireAuth, async (req, res) => {
|
||||||
|
if (!IS_COMMUNITY || !ALLOW_COMMUNITY_GITHUB_ACTIVATION) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: "Запуск установки PRO из панели отключён.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (communityPrivateInstallBusy) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: "Установка PRO уже выполняется. Через 1–2 минуты обновите страницу или см. журнал ниже.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = typeof req.body?.githubToken === "string" ? req.body.githubToken.trim() : "";
|
||||||
|
if (!validateGithubBearerToken(token)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"Нужен GitHub-токен с доступом к приватному репозиторию (classic: repo; fine-grained: Contents Read для репозитория со scripts/install.sh).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmpDir = "";
|
||||||
|
communityPrivateInstallBusy = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const timeoutMs = Number(process.env.COMMUNITY_INSTALL_FETCH_MS || 60_000) || 60_000;
|
||||||
|
const tmo = setTimeout(() => ac.abort(), timeoutMs);
|
||||||
|
const ghRes = await fetch(COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL, {
|
||||||
|
redirect: "follow",
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${token}`,
|
||||||
|
Accept: "*/*",
|
||||||
|
"User-Agent": "amnezia-web-community-private-install",
|
||||||
|
},
|
||||||
|
signal: ac.signal,
|
||||||
|
}).finally(() => clearTimeout(tmo));
|
||||||
|
|
||||||
|
const buf = Buffer.from(await ghRes.arrayBuffer());
|
||||||
|
if (!ghRes.ok || buf.byteLength === 0) {
|
||||||
|
communityPrivateInstallBusy = false;
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Не удалось скачать install (${ghRes.status}). Проверьте токен и URL.`,
|
||||||
|
urlForDebug: privateInstallScriptUrlLogged(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (buf.byteLength > PRIVATE_INSTALL_SCRIPT_MAX_BYTES) {
|
||||||
|
communityPrivateInstallBusy = false;
|
||||||
|
return res.status(400).json({ error: "Скачанный скрипт слишком большой — отказ." });
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir = fs.mkdtempSync(path.join("/tmp", "amnezia-priv-inst-"));
|
||||||
|
const scriptPath = path.join(tmpDir, "install.sh");
|
||||||
|
fs.writeFileSync(scriptPath, buf);
|
||||||
|
fs.chmodSync(scriptPath, 0o700);
|
||||||
|
|
||||||
|
ensureDataDir();
|
||||||
|
const logAbs = path.join(DATA_DIR, "community-install-last.log");
|
||||||
|
const header = `\n${"=".repeat(60)}\n${new Date().toISOString()} — старт install из UI (COMMUNITY)\nURL: ${COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL}\n${"=".repeat(60)}\n`;
|
||||||
|
fs.appendFileSync(logAbs, header);
|
||||||
|
|
||||||
|
const logStream = fs.createWriteStream(logAbs, { flags: "a" });
|
||||||
|
|
||||||
|
const child = spawn("bash", [scriptPath], {
|
||||||
|
cwd: tmpDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GITHUB_TOKEN: token,
|
||||||
|
GH_TOKEN: token,
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.pipe(logStream, { end: false });
|
||||||
|
child.stderr.pipe(logStream, { end: false });
|
||||||
|
|
||||||
|
let finalized = false;
|
||||||
|
const finalize = (code = null, signal = null, errText = "") => {
|
||||||
|
if (finalized) return;
|
||||||
|
finalized = true;
|
||||||
|
communityPrivateInstallBusy = false;
|
||||||
|
const foot = `\n--- завершено ${new Date().toISOString()}, code=${code ?? "?"}${
|
||||||
|
signal ? ` signal=${signal}` : ""
|
||||||
|
}${errText ? ` err=${errText}` : ""} ---\n`;
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(logAbs, foot);
|
||||||
|
} catch {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
logStream.end(() => {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
child.once("exit", (code, signal) => finalize(code, signal));
|
||||||
|
child.once("error", (err) => {
|
||||||
|
console.warn("community-private-install bash:", err?.message || err);
|
||||||
|
finalize(null, null, String(err.message || err));
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(202).json({
|
||||||
|
ok: true,
|
||||||
|
queued: true,
|
||||||
|
message:
|
||||||
|
"Установка PRO запущена. Панель может прерваться — через 2–5 минут откройте её снова по тому же адресу. Журнал пишется в /data/community-install-last.log в том контейнере, где была FREE-панель (до возможной замены образа).",
|
||||||
|
logInsideContainer: "community-install-last.log",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
communityPrivateInstallBusy = false;
|
||||||
|
if (tmpDir) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const msg =
|
||||||
|
e?.name === "AbortError"
|
||||||
|
? "Таймаут загрузки install.sh с GitHub."
|
||||||
|
: e?.message || String(e);
|
||||||
|
return res.status(400).json({ error: msg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user