Админка в master: секретный путь, ссылка, интервал, force, блокировка входа, история

Админка нужна на «голом» сервере, поэтому возвращаю её в основную ветку. Она
выключена, пока не заданы 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-образе (том настроек, запись истории).
This commit is contained in:
2026-10-10 11:12:29 +03:00
parent 962681630f
commit 5b3c9dc00a
17 changed files with 1569 additions and 54 deletions

View File

@@ -294,6 +294,11 @@ PyPI не раздаёт вовсе. Зеркала отстают по-разн
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
| `ADMIN_PATH` | — | api+frontend | секретный путь админки; пусто = админка выключена |
| `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | логин и пароль администратора |
| `ADMIN_MAX_ATTEMPTS` | `3` | api | сколько неудачных попыток входа допускается |
| `ADMIN_LOCKOUT_MINUTES` | `15` | api | на сколько минут блокировать вход после них |
| `SETTINGS_FILE` | `data/settings.json` | api | где хранить ссылку, интервал и историю входов |
| `API_PORT` | `8000` | docker | внешний порт api (публикуется на 127.0.0.1) |
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) |
@@ -301,6 +306,42 @@ PyPI не раздаёт вовсе. Зеркала отстают по-разн
`.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные
переменные окружения.
## Админка
Необязательная страница для управления источником данных. Отдельного входа на
сайте нет: панель открывается по секретному пути `ADMIN_PATH`, а пока
`ADMIN_LOGIN`/`ADMIN_PASSWORD` пусты, админка выключена и наружу ничего не
торчит (маршруты `/api/admin/*` не регистрируются).
Включить: заполнить в `.env` четыре переменные и перезапустить службу.
```ini
ADMIN_PATH=/panel-8f3a91x7 # придумайте длинный и неочевидный
ADMIN_LOGIN=admin
ADMIN_PASSWORD=<длинный пароль>
```
Что умеет:
- **посмотреть состояние**: сколько групп, когда обновлялось, дата файла на
Диске, изменился ли файл при последнем чтении (`sha256`), есть ли ошибки;
- **вставить новую ссылку** на таблицу: она проверяется скачиванием и разбором
до сохранения — нерабочая не применится, данные на сайте не пострадают;
- **задать интервал обновления** в минутах (`1…1440`);
- **обновить сейчас**: скачивает таблицу принудительно, не дожидаясь интервала
(по интервалу файл перечитывается, только если на Диске изменилась дата);
- **видеть входы**: последняя попытка (откуда, когда, успешна ли) и последний
успешный вход — и в панели, и в логе службы.
Защита входа: после `ADMIN_MAX_ATTEMPTS` неудач (по умолчанию 3) вход с этого
адреса блокируется на `ADMIN_LOCKOUT_MINUTES` минут (по умолчанию 15). Сессия —
подписанная cookie на 12 часов.
Настройки (ссылка, интервал, история входов) хранятся в `SETTINGS_FILE`
(по умолчанию `data/settings.json` в корне проекта; в docker — в томе
`schedule_data`). Если файл недоступен для записи, изменения живут в памяти до
перезапуска, а панель предупредит об этом.
## Структура кода
```
@@ -308,16 +349,19 @@ install.sh интерактивная установка на сер
src/
backend/
main.py FastAPI: маршруты, create_app(cache), /healthz
admin.py админка: cookie-сессия, блокировка входа, ссылка, интервал, force
schedule/
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
скрытые столбцы, красный текст, серийные даты
source.py скачивание файла по публичной ссылке Я.Диска
lessons.py разбор ячеек занятий (общий конвейер ролей)
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
cache.py ScheduleCache: блокировка, интервал, stale-while-error
cache.py ScheduleCache: блокировка, интервал, stale-while-error, force
settings.py хранилище настроек админки (ссылка, интервал, история входов)
errors.py ScheduleError
frontend/
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
admin.html страница админки (вне static: прямой доступ закрыт)
static/
index.html, app.js, style.css
manifest.json PWA: имя, иконки, standalone-режим
@@ -335,9 +379,13 @@ tests/
test_xlsx.py чтение книги
test_parsing.py разбор листов, недели, индексы
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
test_admin.py админка: вход, блокировка, история, ссылка/интервал/force
test_settings.py хранилище настроек админки
test_deploy.py проверка, что образы копируют все нужные файлы
```
Данные админки (`data/settings.json`) — рабочие, в git не попадают.
## Как устроена таблица-источник
Лист «Бакалавры и магистры» — это сетка: