Админка нужна на «голом» сервере, поэтому возвращаю её в основную ветку. Она выключена, пока не заданы ADMIN_LOGIN/ADMIN_PASSWORD: маршруты /api/admin/* не регистрируются, страницы нет — обычная установка ничего не замечает. Панель (открывается по секретному пути ADMIN_PATH, кнопки входа на сайте нет, вход по логину/паролю): - состояние: группы, когда обновлялось, дата файла на Диске, изменился ли файл при последнем чтении (sha256), ошибки, путь файла настроек; - новая ссылка на таблицу: проверяется скачиванием, при ошибке откат; - интервал обновления (TTL) в минутах; - «Обновить сейчас»: принудительно перечитывает таблицу (качает файл всегда, интервал — только если на Диске изменилась дата); - история входов: последняя попытка (откуда, когда, успешна ли) и последний успешный вход — и в панели, и в логе службы. Защита входа: ADMIN_MAX_ATTEMPTS неудач (по умолчанию 3), затем блокировка на ADMIN_LOCKOUT_MINUTES минут (по умолчанию 15) — всё из .env. Реализация: - backend/admin.py: cookie-сессия на HMAC (ключ — пароль, 12 ч, HttpOnly, SameSite, Secure за https), сравнение за постоянное время, реальный IP из X-Forwarded-For, логирование каждой попытки; - schedule/settings.py: ссылка, интервал и история входов в JSON; путь по умолчанию data/settings.json (в docker — том schedule_data), при недоступности файла настройки живут в памяти и панель предупреждает; - cache.py: apply_link (с проверкой и откатом), force_reload, state(); - frontend: страница админки по ADMIN_PATH (вне static, noindex) и прокси /api/admin/* с телом и Set-Cookie; - install.sh пишет переменные админки в .env, Dockerfile копирует admin.py. Тесты: 156. Проверено локально (страница, 3 попытки -> блокировка, история) и в собранном docker-образе (том настроек, запись истории).
357 lines
16 KiB
HTML
357 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>Админка расписания</title>
|
||
<link rel="icon" type="image/svg+xml" href="/img/app-icon.svg">
|
||
<link rel="stylesheet" href="/style.css">
|
||
<style>
|
||
.admin-wrap { max-width: 760px; margin: 0 auto; padding: 26px 18px 60px; }
|
||
.admin-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||
.admin-head img { width: 40px; height: 40px; }
|
||
.admin-title { font-size: 22px; font-weight: 700; color: var(--text); }
|
||
.admin-sub { color: var(--muted); font-size: 14px; margin-bottom: 18px; }
|
||
.admin-card {
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
padding: 16px 18px; margin-bottom: 14px;
|
||
}
|
||
.admin-card h2 { font-size: 15px; margin-bottom: 10px; color: var(--text); }
|
||
.admin-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
|
||
.admin-field { display: flex; flex-direction: column; gap: 4px; flex: 1 1 260px; min-width: 0; }
|
||
.admin-field label { font-size: 13px; color: var(--muted); }
|
||
.admin-field input {
|
||
font-size: 14px; padding: 8px 10px; border: 1px solid var(--border-2);
|
||
background: var(--surface-2); color: var(--text); width: 100%; min-width: 0;
|
||
}
|
||
.admin-field input:focus { outline: 2px solid var(--accent-blue); outline-offset: -1px; }
|
||
.admin-btn {
|
||
font-size: 14px; font-weight: 600; padding: 9px 14px; cursor: pointer;
|
||
border: 1px solid var(--accent); background: var(--accent); color: #fff;
|
||
}
|
||
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
|
||
.admin-btn.danger { background: var(--accent-red-bright); border-color: var(--accent-red-bright); }
|
||
.admin-btn[disabled] { opacity: .55; cursor: default; }
|
||
.admin-status { display: flex; flex-wrap: wrap; gap: 10px; }
|
||
.admin-chip {
|
||
background: var(--surface-2); border: 1px solid var(--border); padding: 8px 12px; min-width: 0;
|
||
}
|
||
.admin-chip .k { font-size: 12px; color: var(--muted); }
|
||
.admin-chip .v { font-size: 14px; font-weight: 600; color: var(--text); word-break: break-word; overflow-wrap: anywhere; }
|
||
.admin-error { color: var(--accent-red-bright); font-size: 13px; margin-top: 10px; word-break: break-word; overflow-wrap: anywhere; }
|
||
.admin-warn { color: #b8860b; font-size: 13px; margin-top: 10px; word-break: break-word; }
|
||
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
|
||
.hidden { display: none !important; }
|
||
.bad { color: var(--accent-red-bright); }
|
||
.good { color: #2f9e5f; }
|
||
|
||
/* всплывающие уведомления вместо alert(): сами исчезают через 15 секунд */
|
||
#toast-wrap {
|
||
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
|
||
z-index: 9999; display: flex; flex-direction: column; gap: 8px;
|
||
width: min(560px, calc(100vw - 32px));
|
||
}
|
||
.toast {
|
||
display: flex; align-items: flex-start; gap: 10px;
|
||
background: var(--surface); border: 1px solid var(--border-2);
|
||
border-left: 4px solid var(--accent-blue);
|
||
box-shadow: 0 8px 24px var(--shadow);
|
||
padding: 12px 14px; font-size: 14px; color: var(--text);
|
||
white-space: pre-line; animation: toast-in .18s ease-out;
|
||
}
|
||
.toast.ok { border-left-color: #2f9e5f; }
|
||
.toast.error { border-left-color: var(--accent-red-bright); }
|
||
.toast.hide { opacity: 0; transition: opacity .3s ease; }
|
||
.toast .toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||
.toast .toast-close {
|
||
flex: 0 0 auto; border: none; background: none; color: var(--muted);
|
||
font-size: 15px; line-height: 1; cursor: pointer; padding: 2px 4px;
|
||
}
|
||
.toast .toast-close:hover { color: var(--text); }
|
||
@keyframes toast-in {
|
||
from { opacity: 0; transform: translateY(6px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="admin-wrap">
|
||
<div class="admin-head">
|
||
<img src="/img/owl.svg" alt="">
|
||
<div class="admin-title">Админка расписания</div>
|
||
</div>
|
||
<div class="admin-sub">Управление источником данных и обновлением. Страница доступна только по секретной ссылке.</div>
|
||
|
||
<section id="login-card" class="admin-card hidden">
|
||
<h2>Вход</h2>
|
||
<form id="login-form" autocomplete="off">
|
||
<div class="admin-field"><label for="login">Логин</label><input id="login" required></div>
|
||
<div class="admin-field" style="margin-top:10px"><label for="password">Пароль</label><input id="password" type="password" required></div>
|
||
<div class="admin-row" style="margin-top:12px">
|
||
<button class="admin-btn" type="submit">Войти</button>
|
||
</div>
|
||
</form>
|
||
<div id="login-error" class="admin-error hidden"></div>
|
||
</section>
|
||
|
||
<section id="panel" class="hidden">
|
||
<div class="admin-card">
|
||
<h2>Состояние</h2>
|
||
<div class="admin-status" id="status-chips"></div>
|
||
<div id="state-error" class="admin-error hidden"></div>
|
||
<div id="state-warn" class="admin-warn hidden"></div>
|
||
</div>
|
||
|
||
<div class="admin-card">
|
||
<h2>Входы в админку</h2>
|
||
<div class="admin-status" id="auth-chips"></div>
|
||
<div class="admin-hint">Каждая попытка входа запоминается: откуда, когда и чем закончилась. После нескольких неудач вход временно блокируется.</div>
|
||
</div>
|
||
|
||
<div class="admin-card">
|
||
<h2>Ссылка на расписание</h2>
|
||
<div class="admin-row">
|
||
<div class="admin-field">
|
||
<label for="link">Публичная ссылка Яндекс.Диска</label>
|
||
<input id="link" spellcheck="false" placeholder="https://disk.yandex.ru/i/...">
|
||
</div>
|
||
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
|
||
</div>
|
||
<div class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
|
||
Значение хранится в файле настроек и <b>имеет приоритет над .env</b> — именно по нему сайт берёт расписание.</div>
|
||
</div>
|
||
|
||
<div class="admin-card">
|
||
<h2>Обновление</h2>
|
||
<div class="admin-row">
|
||
<div class="admin-field" style="flex:0 1 200px">
|
||
<label for="interval">Интервал, минут</label>
|
||
<input id="interval" type="number" min="1" max="1440" step="1">
|
||
</div>
|
||
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить интервал</button>
|
||
<button class="admin-btn" id="refresh" type="button">Обновить сейчас</button>
|
||
</div>
|
||
<div class="admin-hint">По интервалу расписание перечитывается, только если на Диске изменилась дата файла. Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.</div>
|
||
</div>
|
||
|
||
<div class="admin-card">
|
||
<button class="admin-btn danger" id="logout" type="button">Выйти</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="toast-wrap"></div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const TOAST_MS = 15000;
|
||
|
||
/** Всплывающее уведомление: само скрывается через 15 секунд. */
|
||
function toast(message, kind = "info", ms = TOAST_MS) {
|
||
const wrap = $("toast-wrap");
|
||
const el = document.createElement("div");
|
||
el.className = "toast " + kind;
|
||
const text = document.createElement("div");
|
||
text.className = "toast-text";
|
||
text.textContent = message;
|
||
const close = document.createElement("button");
|
||
close.type = "button";
|
||
close.className = "toast-close";
|
||
close.textContent = "✕";
|
||
close.setAttribute("aria-label", "Закрыть");
|
||
el.append(text, close);
|
||
wrap.appendChild(el);
|
||
const hide = () => {
|
||
clearTimeout(timer);
|
||
el.classList.add("hide");
|
||
setTimeout(() => el.remove(), 300);
|
||
};
|
||
const timer = setTimeout(hide, ms);
|
||
close.addEventListener("click", hide);
|
||
return hide;
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
let resp;
|
||
try {
|
||
resp = await fetch("/api/admin" + path, { credentials: "same-origin", ...options });
|
||
} catch (err) {
|
||
return { ok: false, status: 0, data: { detail: "Сеть недоступна: " + err.message } };
|
||
}
|
||
let data = null;
|
||
try { data = await resp.json(); } catch (_) {}
|
||
return { ok: resp.ok, status: resp.status, data };
|
||
}
|
||
|
||
function post(path, payload) {
|
||
return api(path, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload || {}),
|
||
});
|
||
}
|
||
|
||
function fmt(value) {
|
||
if (!value) return "—";
|
||
const d = new Date(value);
|
||
return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" });
|
||
}
|
||
|
||
function chip(key, value, cls) {
|
||
const div = document.createElement("div");
|
||
div.className = "admin-chip";
|
||
div.innerHTML = `<div class="k">${key}</div><div class="v"></div>`;
|
||
const v = div.querySelector(".v");
|
||
v.textContent = value;
|
||
if (cls) v.classList.add(cls);
|
||
return div;
|
||
}
|
||
|
||
function loginText(info) {
|
||
if (!info) return "нет данных";
|
||
// у успешного входа поля ok нет — там итог и так известен
|
||
const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`;
|
||
return `${fmt(info.at)} · ${info.ip}${status}`;
|
||
}
|
||
|
||
function renderState(state) {
|
||
const chips = $("status-chips");
|
||
chips.innerHTML = "";
|
||
chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`));
|
||
chips.appendChild(chip("Обновлено", fmt(state.last_updated)));
|
||
chips.appendChild(chip(
|
||
"Файл при последнем обновлении",
|
||
state.changed ? "изменился" : "без изменений",
|
||
state.changed ? "good" : null,
|
||
));
|
||
chips.appendChild(chip("Дата файла на Диске", fmt(state.table_modified)));
|
||
chips.appendChild(chip("Групп в расписании", String(state.groups)));
|
||
if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash));
|
||
chips.appendChild(chip("Текущая ссылка", state.link || "не задана"));
|
||
if (state.settings_file) chips.appendChild(chip("Файл настроек", state.settings_file));
|
||
|
||
const auth = $("auth-chips");
|
||
auth.innerHTML = "";
|
||
const attempt = state.last_attempt;
|
||
auth.appendChild(chip(
|
||
"Последняя попытка входа",
|
||
loginText(attempt),
|
||
attempt ? (attempt.ok ? "good" : "bad") : null,
|
||
));
|
||
auth.appendChild(chip("Последний успешный вход", loginText(state.last_success)));
|
||
const throttle = state.throttle || {};
|
||
if (throttle.locked_until) {
|
||
auth.appendChild(chip("Вход заблокирован до", fmt(throttle.locked_until), "bad"));
|
||
} else if (throttle.max_attempts) {
|
||
auth.appendChild(chip(
|
||
"Осталось попыток",
|
||
`${throttle.attempts_left} из ${throttle.max_attempts}`,
|
||
));
|
||
}
|
||
|
||
$("link").value = state.link || "";
|
||
$("interval").value = state.interval_minutes;
|
||
|
||
const err = $("state-error");
|
||
err.textContent = state.error ? "Ошибка загрузки: " + state.error : "";
|
||
err.classList.toggle("hidden", !state.error);
|
||
|
||
const warn = $("state-warn");
|
||
warn.textContent = state.settings_save_error
|
||
? "Настройки не сохраняются на диск: " + state.settings_save_error +
|
||
" — после перезапуска службы ссылка и интервал вернутся к значениям из .env."
|
||
: "";
|
||
warn.classList.toggle("hidden", !state.settings_save_error);
|
||
}
|
||
|
||
function showLogin(show) {
|
||
$("login-card").classList.toggle("hidden", !show);
|
||
$("panel").classList.toggle("hidden", show);
|
||
if (show) $("login").focus();
|
||
}
|
||
|
||
function busy(button, text) {
|
||
const original = button.textContent;
|
||
button.disabled = true;
|
||
button.textContent = text;
|
||
return () => { button.disabled = false; button.textContent = original; };
|
||
}
|
||
|
||
async function loadState() {
|
||
const r = await api("/state");
|
||
if (r.status === 401) { showLogin(true); return; }
|
||
if (!r.ok) {
|
||
toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error");
|
||
return;
|
||
}
|
||
showLogin(false);
|
||
renderState(r.data);
|
||
}
|
||
|
||
async function withButton(button, text, fn) {
|
||
const done = busy(button, text);
|
||
try { await fn(); } finally { done(); }
|
||
}
|
||
|
||
$("login-form").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const error = $("login-error");
|
||
error.classList.add("hidden");
|
||
const r = await post("/login", { login: $("login").value, password: $("password").value });
|
||
if (!r.ok) {
|
||
error.textContent = (r.data && r.data.detail) || "Не удалось войти";
|
||
error.classList.remove("hidden");
|
||
return;
|
||
}
|
||
$("password").value = "";
|
||
toast("Вход выполнен.", "ok", 5000);
|
||
await loadState();
|
||
});
|
||
|
||
$("save-link").addEventListener("click", () => withButton($("save-link"), "Проверяю…", async () => {
|
||
const r = await post("/link", { link: $("link").value });
|
||
if (!r.ok) {
|
||
toast("Ссылка не принята: " + ((r.data && r.data.detail) || r.status), "error");
|
||
return;
|
||
}
|
||
renderState(r.data);
|
||
toast("Ссылка сохранена, расписание загружено.", "ok");
|
||
}));
|
||
|
||
$("save-interval").addEventListener("click", () => withButton($("save-interval"), "Сохраняю…", async () => {
|
||
const r = await post("/interval", { interval_minutes: Number($("interval").value) });
|
||
if (!r.ok) {
|
||
toast("Интервал не сохранён: " + ((r.data && r.data.detail) || r.status), "error");
|
||
return;
|
||
}
|
||
toast(`Интервал обновления: ${r.data.interval_minutes} мин.`, "ok");
|
||
await loadState();
|
||
}));
|
||
|
||
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
|
||
const r = await post("/refresh");
|
||
if (!r.ok) {
|
||
toast("Обновить не удалось: " + ((r.data && r.data.detail) || r.status), "error");
|
||
} else if (r.data) {
|
||
renderState(r.data);
|
||
toast(
|
||
r.data.changed
|
||
? "Готово: в файле обнаружены изменения — расписание обновлено."
|
||
: "Готово: файл не изменился (на Диске та же версия).\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту.",
|
||
r.data.changed ? "ok" : "info",
|
||
);
|
||
}
|
||
await loadState();
|
||
}));
|
||
|
||
$("logout").addEventListener("click", async () => {
|
||
await post("/logout");
|
||
showLogin(true);
|
||
});
|
||
|
||
loadState();
|
||
</script>
|
||
</body>
|
||
</html>
|