feat(panel): donor strip + Telegram sponsor in footer

Public GET /api/panel/footer with env-backed URLs (defaults: Boosty + t.me sponsor). install.sh forwards PANEL_FOOTER_*. Promo only in admin SPA, not nginx landing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-15 03:57:36 +03:00
parent 8d6bf1a45c
commit bb007ad644
7 changed files with 186 additions and 5 deletions

View File

@@ -64,6 +64,11 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
| `AWG_PROFILES` | _(нет)_ | Несколько инстансов; см. примеры в репозитории PRO |
| `COMMUNITY_UPGRADE_URL` | Страница подписки PRO на Boosty (зашита в код по умолчанию) | Переопределите, если смените уровень подписки |
| `COMMUNITY_UPGRADE_PITCH` | _(текст по умолчанию в коде)_ | Текст под заголовком базовой версии |
| `PANEL_FOOTER_DONATE_URL` | Как **`COMMUNITY_UPGRADE_URL`** | Ссылка «донат» в **шапке и подвале** веб‑панели (**не** добавляется на nginxлендинг пользователей). |
| `PANEL_FOOTER_TELEGRAM_URL` | `https://t.me/lot_andrey` | Ссылка Telegramспонсора / канала (там же: только панель). |
| `PANEL_FOOTER_DONATE_LABEL` | `Поддержать проект` | Текст якоря доната |
| `PANEL_FOOTER_TELEGRAM_LABEL` | `Telegramканал спонсора` | Текст якоря Telegram |
| `PANEL_FOOTER_PROMO_SUBTITLE` | _(строка по умолчанию в коде)_ | Поясняющий текст в верхней полосе панели |
| `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 |

View File

@@ -1,6 +1,6 @@
{
"name": "amnezia-admin",
"version": "1.2.19",
"version": "1.2.20",
"private": true,
"description": "amnezia_web — базовая панель AmneziaWG (FREE: просмотр и удаление клиентов; полная версия — PRO).",
"license": "MIT",

View File

@@ -147,6 +147,62 @@ function fmtBytesRu(n) {
return `${Math.round(x / (1024 * 102.4)) / 10} MiB`;
}
/** Ссылки доната и Telegramспонсора: шапка + подвал панели (`GET /api/panel/footer`). */
function wirePanelPromoAnchor(el, url, label) {
const u = typeof url === "string" ? url.trim() : "";
const lb = typeof label === "string" ? label.trim() : "";
if (!el || !u || !lb) {
if (el) {
el.classList.add("hidden");
el.removeAttribute("href");
el.textContent = "";
}
return false;
}
el.href = u;
el.textContent = lb;
el.classList.remove("hidden");
return true;
}
async function hydratePanelPromoFooter() {
const strip = document.getElementById("panel-promo-strip");
const sub = document.getElementById("panel-promo-subtitle");
const aDonTop = document.getElementById("panel-strip-donate");
const sepTop = document.getElementById("panel-strip-sep");
const aTgTop = document.getElementById("panel-strip-telegram");
const gap1 = document.getElementById("footer-promo-gap1");
const aDonF = document.getElementById("footer-promo-donate");
const sepMid = document.getElementById("footer-promo-sep-mid");
const aTgF = document.getElementById("footer-promo-telegram");
try {
const r = await fetch("/api/panel/footer", { credentials: "same-origin" });
if (!r.ok) return;
const j = await r.json().catch(() => null);
if (!j || typeof j !== "object") return;
const subtitle = typeof j.promoSubtitle === "string" ? j.promoSubtitle.trim() : "";
if (sub) sub.textContent = subtitle;
const okD = wirePanelPromoAnchor(aDonTop, j.donateUrl, j.donateLabel);
const okT = wirePanelPromoAnchor(aTgTop, j.telegramUrl, j.telegramLabel);
if (sepTop) sepTop.classList.toggle("hidden", !(okD && okT));
wirePanelPromoAnchor(aDonF, j.donateUrl, j.donateLabel);
wirePanelPromoAnchor(aTgF, j.telegramUrl, j.telegramLabel);
const footerD = aDonF && !aDonF.classList.contains("hidden");
const footerT = aTgF && !aTgF.classList.contains("hidden");
if (gap1) gap1.classList.toggle("hidden", !(footerD || footerT));
if (sepMid) sepMid.classList.toggle("hidden", !(footerD && footerT));
if (strip && (okD || okT)) strip.classList.remove("hidden");
} catch {
/* офлайн или сбой — блок остаётся скрытым */
}
}
function ensureEditionBannerInstallLogPanel() {
if (!editionBanner) return null;
let wrap = editionBanner.querySelector(".edition-banner-install-log");
@@ -1603,4 +1659,5 @@ async function boot() {
}
}
void hydratePanelPromoFooter();
boot();

View File

@@ -7,9 +7,20 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css?v=1.2.19">
<link rel="stylesheet" href="/styles.css?v=1.2.20">
</head>
<body class="page">
<aside id="panel-promo-strip" class="panel-promo-strip hidden" role="complementary" aria-label="Поддержка проекта">
<div class="panel-promo-strip-inner">
<p id="panel-promo-subtitle" class="panel-promo-subtitle muted"></p>
<div class="panel-promo-links">
<a id="panel-strip-donate" class="panel-promo-a hidden" target="_blank" rel="noopener noreferrer"></a>
<span id="panel-strip-sep" class="panel-promo-sep hidden" aria-hidden="true">·</span>
<a id="panel-strip-telegram" class="panel-promo-a hidden" target="_blank" rel="noopener noreferrer"></a>
</div>
</div>
</aside>
<div id="login-gate" class="gate hidden" aria-hidden="true">
<div class="gate-card">
<p class="eyebrow">Панель сервера</p>
@@ -222,9 +233,13 @@
<strong class="support-title">amnezia_web</strong>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://github.com/andrey271192/amnezia_web" target="_blank" rel="noopener noreferrer">GitHub</a>
<span id="footer-promo-gap1" class="support-sep support-footer-promo-gap hidden" aria-hidden="true">·</span>
<a id="footer-promo-donate" class="support-promo-link hidden" target="_blank" rel="noopener noreferrer"></a>
<span id="footer-promo-sep-mid" class="support-sep hidden" aria-hidden="true">·</span>
<a id="footer-promo-telegram" class="support-promo-link hidden" target="_blank" rel="noopener noreferrer"></a>
</div>
<p class="support-blurb">
Базовая панель: AWGклиенты, статусы и MTProtoпрокси (Docker) без сторонней интеграции Telegramботов.
Базовая панель: AWGклиенты, статусы и MTProtoпрокси (Docker). Ссылки поддержки автора только в этом интерфейсе панели, не на отдельных пользовательских лендингах.
</p>
</footer>
@@ -262,6 +277,6 @@
</div>
</dialog>
<script src="/app.js?v=1.2.19" type="module"></script>
<script src="/app.js?v=1.2.20" type="module"></script>
</body>
</html>

View File

@@ -43,6 +43,64 @@ body.page {
flex-direction: column;
}
.panel-promo-strip {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 40;
border-bottom: 1px solid rgba(180, 83, 9, 0.28);
background: linear-gradient(90deg, rgba(254, 243, 199, 0.95), rgba(255, 237, 213, 0.97));
backdrop-filter: blur(6px);
}
.panel-promo-strip-inner {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1.25rem;
max-width: min(1120px, 100%);
margin: 0 auto;
padding: 0.5rem clamp(1rem, 3vw, 1.5rem);
}
.panel-promo-subtitle {
margin: 0;
flex: 1 1 16rem;
font-size: 0.84rem;
line-height: 1.5;
color: var(--text);
}
.panel-promo-links {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.65rem;
}
.panel-promo-a {
font-weight: 650;
font-size: 0.88rem;
color: #9a3412;
text-decoration: none;
white-space: nowrap;
}
.panel-promo-a:hover {
text-decoration: underline;
}
.panel-promo-sep {
color: var(--muted);
font-weight: 700;
user-select: none;
}
.support-line .support-promo-link {
white-space: nowrap;
}
.shell {
flex: 1 0 auto;
max-width: min(1120px, 100%);

View File

@@ -265,7 +265,9 @@ elif [[ -f "${INSTALL_DIR}/.amnezia-panel-edition" ]]; then
echo "→ AMNEZIA_EDITION из ${INSTALL_DIR}/.amnezia-panel-edition: ${__PE}"
fi
fi
for __ce_var in COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH ALLOW_COMMUNITY_GITHUB_ACTIVATION COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL COMMUNITY_INSTALL_FETCH_MS PRIVATE_INSTALL_SCRIPT_MAX_BYTES COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE FREE_PANEL_CONTAINER_FOR_PRO_INSTALL FREE_LANDING_CONTAINER_FOR_PRO_INSTALL STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL COMMUNITY_INSTALL_LOG_TAIL_BYTES COMMUNITY_INSTALL_LOG_API_CHUNK_MAX COMMUNITY_HELPER_SKIP_PREPARE_TOOLS; do
for __ce_var in COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH PANEL_FOOTER_DONATE_URL PANEL_FOOTER_TELEGRAM_URL \
PANEL_FOOTER_DONATE_LABEL PANEL_FOOTER_TELEGRAM_LABEL PANEL_FOOTER_PROMO_SUBTITLE \
ALLOW_COMMUNITY_GITHUB_ACTIVATION COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL COMMUNITY_INSTALL_FETCH_MS PRIVATE_INSTALL_SCRIPT_MAX_BYTES COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE FREE_PANEL_CONTAINER_FOR_PRO_INSTALL FREE_LANDING_CONTAINER_FOR_PRO_INSTALL STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL COMMUNITY_INSTALL_LOG_TAIL_BYTES COMMUNITY_INSTALL_LOG_API_CHUNK_MAX COMMUNITY_HELPER_SKIP_PREPARE_TOOLS; do
if [[ -n "${!__ce_var:-}" ]]; then
RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" )
fi

View File

@@ -69,6 +69,45 @@ const COMMUNITY_UPGRADE_PITCH =
process.env.COMMUNITY_UPGRADE_PITCH?.trim() ||
"В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. В FREE: удаление клиента с сервера + отдельный раздел установки Telegram MTProto-прокси (Docker-контейнер). Полная сборка AWG-панели — приватный репозиторий amnezia_web-PRO; доступ по подписке Boosty.";
const PANEL_FOOTER_TELEGRAM_DEFAULT = "https://t.me/lot_andrey";
function sanitizeHttpsHttpUrl(raw) {
const s = String(raw || "").trim();
if (!s) return "";
try {
const u = new URL(s);
if (u.protocol === "http:" || u.protocol === "https:") return u.href;
} catch {
/* ignore */
}
return "";
}
/** Донат + Telegramспонсор: только в админ‑панели (не пользовательские лендинги). Переопределяется через env контейнера. */
function panelPromoFooterPayload() {
const donateResolved =
sanitizeHttpsHttpUrl(
process.env.PANEL_FOOTER_DONATE_URL?.trim() || COMMUNITY_UPGRADE_URL.trim(),
) || "";
const telegramResolved =
sanitizeHttpsHttpUrl(
process.env.PANEL_FOOTER_TELEGRAM_URL?.trim() || PANEL_FOOTER_TELEGRAM_DEFAULT,
) || "";
return {
donateUrl: donateResolved,
telegramUrl: telegramResolved,
donateLabel:
process.env.PANEL_FOOTER_DONATE_LABEL?.trim() || "Поддержать проект",
telegramLabel:
process.env.PANEL_FOOTER_TELEGRAM_LABEL?.trim() ||
"Telegramканал спонсора",
promoSubtitle:
process.env.PANEL_FOOTER_PROMO_SUBTITLE?.trim() ||
"Поддержите разработку и канал спонсора — ссылки есть вверху и внизу панели. На пользовательские сайты‑лендинги это не добавляется.",
};
}
function editionPayload() {
return {
tier: IS_COMMUNITY ? "community" : "pro",
@@ -2115,6 +2154,11 @@ app.get("/health", (_req, res) => {
res.json({ ok: true, version: PANEL_VERSION });
});
/** Публичные ссылки доната / Telegramспонсора для интерфейса панели (без авторизации). */
app.get("/api/panel/footer", (_req, res) => {
res.json(panelPromoFooterPayload());
});
app.get("/api/session", (req, res) => {
if (!readSession(getSessionToken(req))) {
res.status(401).json({ ok: false });