Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cd851aa56 | ||
|
|
79216ce45e | ||
|
|
c6154706db | ||
|
|
95db691a0d | ||
|
|
20cfe276d3 | ||
|
|
e4b5ae99b4 | ||
|
|
280c0b8acc |
+7
-12
@@ -1,17 +1,12 @@
|
||||
# Локальные данные и кеш (только корневые каталоги; якорь «/» обязателен —
|
||||
# иначе «cache/» игнорирует и mods/Music/Services/Cache/ и т.п.)
|
||||
/cache/
|
||||
/database/
|
||||
# Секреты
|
||||
config/passwd
|
||||
|
||||
# Локальные данные и кеш
|
||||
cache/
|
||||
database/
|
||||
|
||||
# Медиа DLNA (том ./dlna -> /lampac/data/dlna)
|
||||
/dlna/
|
||||
dlna/
|
||||
|
||||
# Системное
|
||||
.DS_Store
|
||||
|
||||
# Серверный override (Proxmox: том DLNA -> /mnt/torrents), живёт только на сервере
|
||||
docker-compose.override.yml
|
||||
config/passwd
|
||||
# TorrServer: состояние не трекаем, бинарник — треким
|
||||
/data/ts/*
|
||||
!/data/ts/TorrServer-linux
|
||||
|
||||
@@ -1,140 +1,85 @@
|
||||
# AGENT.md — инструкции для ИИ-агентов
|
||||
# AGENT.md — инструкции для AI-агента
|
||||
|
||||
Работа с проектом: локальный Docker-развёртывание [Lampac NextGen](https://github.com/lampac-nextgen/lampac). Человек читает ответы **только на русском**.
|
||||
## О проекте
|
||||
|
||||
## Источники правды (порядок приоритета)
|
||||
Это **deployment-репозиторий** для локального развёртывания Lampac NextGen (активный форк заброшенного Lampac) в Docker на **Synology NAS**. Исходники форка сюда не клонируются — используется официальный образ `ghcr.io/lampac-nextgen/lampac`.
|
||||
|
||||
1. **Официальная документация** — [docs.lampac.dev](https://docs.lampac.dev) (полный текст страниц: `https://docs.lampac.dev/llms-full.txt`, индекс: `llms.txt`). Не гадать по памяти — проверять в доках.
|
||||
2. **Живой контейнер** — состояние модулей, манифестов и конфигов брать из контейнера:
|
||||
```bash
|
||||
docker exec lampac sh -c 'cat /lampac/module/<Имя>/manifest.json'
|
||||
docker exec lampac cat /lampac/current.conf # merged-конфиг: base + init
|
||||
```
|
||||
3. **Логи контейнера** — `docker logs lampac`.
|
||||
4. Локальные файлы проекта: `docker-compose.yaml`, `config/init.conf`, `mods/`.
|
||||
**Ветка `synology` (текущая)** — как `main`, но `docker-compose.yaml` с **абсолютными путями томов NAS**: `/volume1/docker/lampac/...` (данные проекта), `/volume4/Torrents` (медиа DLNA), `/volume1/homes/prodik/NAS/Курсы` → `/lampac/data/dlna/Cources:ro`. Локального каталога `dlna/` в репо нет — медиа живут на томах.
|
||||
|
||||
## Ключевые правила
|
||||
- Форк: https://github.com/lampac-nextgen/lampac (347+ звёзд, ежедневные коммиты, релизы)
|
||||
- Документация: https://docs.lampac.dev
|
||||
- Образ: `ghcr.io/lampac-nextgen/lampac:latest` (multi-arch: amd64/arm64)
|
||||
- Порт: **9118**, рабочая директория в контейнере: `/lampac`
|
||||
- Последняя задеплоенная версия: `1.61.2` (при установке 01.10.2026)
|
||||
- Git: `ssh://git@gitea.prodik.ru:222/prodik/lampac.git`, ветка **synology** (эта); `main` — то же самое с относительными путями томов; remote настроен как `origin`
|
||||
- Коммиты: `git add -A && git commit && git push origin synology` — секреты/данные отсекает `.gitignore`; в git идут только compose, `init.conf`, `modules/`, доки, `.gitignore`
|
||||
|
||||
### 1. Никогда не правь файлы внутри контейнера/образа
|
||||
## Файлы
|
||||
|
||||
Файлы в `/lampac/module/` (и любые другие в образе) **сгорают при обновлении** (`docker compose pull && up -d`). Включение встроенного модуля делается только пользовательской копией:
|
||||
| Файл | Назначение | Правила |
|
||||
| --- | --- | --- |
|
||||
| `docker-compose.yaml` | Единственный compose-файл | **абсолютные пути томов NAS** (`/volume1/docker/lampac/...`, `/volume4/Torrents`, `Курсы`); фиксированной сети/subnet нет (дефолтная сеть compose) |
|
||||
| `config/init.conf` | Конфиг сервера (JSON) | hot-reload для большинства ключей (кроме `BaseModule.SkipModules` — нужен рестарт); строго валидный JSON |
|
||||
| `config/passwd` | Пароль root, **секрет** | не выводить в чат/логи/коммиты, в `.gitignore` |
|
||||
| `modules/*/manifest.json` | Manifest-оверрайды (`enable: true`) | монтируются поверх `/lampac/module/<Name>/manifest.json`; для модулей, у которых в образе `enable: false` (сейчас: `Tracks`, `Music`, `Tg-notify.bot`, `AdminPanel`) |
|
||||
| `modules/LampaWeb/plugins/extensions.json` | Каталог магазина плагинов (`/extensions`) | override образного файла, добавлена секция «Lampac» (DLNA); после правки сбросить кеш `cache/static/63/` |
|
||||
| `cache/` | Кеш | монтируется в `/lampac/cache` |
|
||||
| `database/` | SQLite-БД (Sync, TimeCode, SISI) | монтируется в `/lampac/database`, не удалять |
|
||||
| `.gitignore` | Игнорируемые пути | в игноре: `config/passwd` (секрет), `cache/`, `database/`, `.DS_Store`; остальное (init.conf, modules/, compose, доки) — переносимое |
|
||||
| `README.md` / `AGENT.md` | Документация | обновлять при любых изменениях развёртывания |
|
||||
|
||||
## Основные команды
|
||||
|
||||
```bash
|
||||
docker cp lampac:/lampac/module/<Имя> "mods/<Имя>"
|
||||
# в mods/<Имя>/manifest.json -> "enable": true
|
||||
# в docker-compose.yaml -> "- ./mods/<Имя>:/lampac/mods/<Имя>"
|
||||
# docker compose up -d
|
||||
docker compose up -d # запуск
|
||||
docker compose pull && docker compose up -d # обновление
|
||||
docker compose restart lampac # рестарт (нужен только для passwd)
|
||||
docker compose logs -f --tail 100 lampac # логи
|
||||
docker compose ps # статус
|
||||
curl -fsS "http://localhost:9118/version?type=hash" # health-check (ожидается 200 + hash)
|
||||
curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:9118/online.js # ожидается 200
|
||||
```
|
||||
|
||||
См. также официальные шаги: [Включить модуль](https://docs.lampac.dev/modules/overview), [Пользовательские модули](https://docs.lampac.dev/maintenance/custom-modules).
|
||||
Проверка работоспособности после любых изменений: health-check выше + контейнер в статусе `healthy`.
|
||||
|
||||
Сейчас в `mods/`: `AdminPanel`, `Tracks`, `Tg-notify.bot`, `TelegramAuth`, `TelegramAuthBot`. **`mods/Music` удалён** (remote-коммит `remove Music`): в образе у Music `manifest.enable: false`, поэтому модуль не грузится, `/music.js` → 404, секция `"Music"` и плагин убраны из `init.conf`. `mods/WatchTogether` тоже удалён (вернуть — той же механикой, он в списке «ещё можно включить» в README).
|
||||
## Правила работы
|
||||
|
||||
**Веб-клиент `lampa-main` — bind-mount из `./lampa-main/`** (в .gitignore). В образе его нет, `LampaCron` качает с GitHub; при пересоздании контейнера без mount клиент исчезает. См. `scripts/restore-lampa-main.sh`.
|
||||
1. **Отвечать по-русски.**
|
||||
2. После любых изменений в проекте — дополнять `README.md` и `AGENT.md`.
|
||||
3. Конфиг менять только в `config/init.conf`; валидность проверять: `python3 -m json.tool config/init.conf`.
|
||||
4. Включение/выключение модулей — через `BaseModule.SkipModules` в `init.conf` (добавить имя = выключить; имя = имя папки в `/lampac/module/`, группы вроде `OnlineAnime` выключаются целиком). `SkipModules` **не hot-reload** — после правки нужен `docker compose restart lampac`. Модули, у которых в образе `manifest.json: enable: false` (`Tracks`, `Music`, `Tg-notify.bot`, `AdminPanel`), включаются только manifest-оверрайдом: копия манифеста с `enable: true` в `modules/<Name>/manifest.json` + монтирование в compose. Список модулей: https://docs.lampac.dev/modules/overview
|
||||
- Текущее состояние: выключены `OnlineAnime`, `OnlineUKR`, `OnlinePaid`, `OnlineGEO`, `Transcoding`, `WebLog`; включены все остальные (TorrServer, Sync/TimeCode/Storage/SyncEvents, Catalog, JacRed, DLNA, SISI, Tracks, Music, Tg-notify.bot, AdminPanel).
|
||||
- Плагины Lampa: `/online.js`, `/sisi.js`, `/dlna.js` и т.д. Большинство Lampac сам подключает клиенту через `/lampainit.js` (см. ниже про `initPlugins`); при необходимости плагин можно добавить вручную по URL.
|
||||
- **Два уровня включения модуля:** (1) `SkipModules`/manifest — модуль скомпилирован и работает на сервере; (2) `LampaWeb.initPlugins.<name>` в `init.conf` — плагин попадает в список, который `/lampainit.js` добавляет клиенту Lampa (`Lampa.Plugins.add`). Без второго флага сервер отвечает, но пункт в Lampa не появится (так было с `dlna` — флаг `initPlugins.dlna` добавлен в init.conf). Дефолты флагов — в `current.conf` секция `LampaWeb.initPlugins`.
|
||||
- **Магазин плагинов** в Lampa (Настройки → Плагины) читает `/extensions` → `LampaWeb/plugins/extensions.json` (каталог образа). Свои записи добавляются через override-файл в `modules/LampaWeb/plugins/extensions.json` (монтирован в compose). Ответ кешируется `Staticache` на диске: `cache/static/<bucket>/` — после правки удалить соответствующий файл (`grep -rl secuses cache/static/`) или весь каталог static, затем перезапросить.
|
||||
5. Новые пользовательские модули класть в `mods/<Name>/` (монтировать в `/lampac/mods/<Name>`), переопределения плагинов — `plugins/lampainit.js` → `/lampac/plugins/override/lampainit.js`.
|
||||
6. Не раскрывать пароль из `passwd`, не коммитить его, не выводить в логи.
|
||||
7. Не открывать порт 9118 в интернет без файрвола/reverse proxy; модули `DLNA`, `Tracks`, `Transcoding`, `GStreamer`, `Catalog` не предназначены для публичного доступа.
|
||||
8. Образ пиннить по тегу при необходимости воспроизводимости (в compose сейчас `:latest`).
|
||||
|
||||
### 2. Механика загрузки модуля (4 независимых фильтра)
|
||||
## Структура контейнера (важные пути)
|
||||
|
||||
Модуль грузится, только если выполнены **все**:
|
||||
|
||||
| Фильтр | Где |
|
||||
|---|---|
|
||||
| `"enable": true` | `manifest.json` модуля (копия в `mods/` для встроенных) |
|
||||
| Имя **не** в `BaseModule.SkipModules` | `base.conf` (в образе) + `config/init.conf` |
|
||||
| Имя попадает в `BaseModule.LoadModules` | если allowlist задан (у нас не задан — грузится всё остальное) |
|
||||
| Runtime-флаг | секция модуля в `config/init.conf`, например `"gst": { "enable": true }` |
|
||||
|
||||
`SkipModules` имеет приоритет над `LoadModules` — модуль из обоих списков **не** грузится. Паттерны: точное имя, группа (`OnlineUKR`), regex (`LME.*`), `.*`.
|
||||
|
||||
### 3. `config/init.conf` — валидный JSON
|
||||
|
||||
JSON **без** trailing comma. Перед применением правок проверять:
|
||||
|
||||
```bash
|
||||
python3 -m json.tool config/init.conf > /dev/null && echo OK
|
||||
```text
|
||||
/lampac/
|
||||
├── init.conf # конфиг (смонтирован с хоста)
|
||||
├── passwd # пароль root (смонтирован с хоста)
|
||||
├── cache/ # кеш (смонтирован)
|
||||
├── database/ # SQLite и бэкапы конфига (смонтирован)
|
||||
├── mods/ # пользовательские модули (Roslyn компилирует .cs на лету)
|
||||
├── module/ # встроенные модули образа
|
||||
├── plugins/override/ # переопределения JS-плагинов
|
||||
└── wwwroot/ # статика Lampa UI
|
||||
```
|
||||
|
||||
После правки конфига: `docker compose restart` (том смонтирован, `up -d` не нужен). После правки `manifest.json` в `mods/` с `dynamic: true` — тоже `restart` (или ждать hot-rebuild). После изменения томов в compose — `docker compose up -d`.
|
||||
Горячая перезагрузка: `init.conf` watcher каждую секунду; `mods/*.cs` перекомпилируются при изменении.
|
||||
|
||||
### 4. Клиентские плагины Lampa
|
||||
## Troubleshooting
|
||||
|
||||
- Встроенные плагины: флаги `LampaWeb.initPlugins` (`tracks`, `dlna`, `catalog`, `sync`, `backup`, ...).
|
||||
- Дополнительные: `LampaWeb.customPlugins` — записи со `status: 1` добавляются как есть; поддерживается плейсхолдер `{localhost}` (подставляется реальный адрес сервера).
|
||||
- Если плагин «не виден» в веб-клиенте — сначала проверить HTTP-статус его URL на сервере (`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9118/<плагин>.js`), а не лезть в клиент.
|
||||
|
||||
### 4.1 Каталог плагинов (store) `/extensions`
|
||||
|
||||
- Раздел «Плагины» веб-клиента грузит каталог с `/extensions` (при `LampaWeb.initPlugins.pirate_store: true`).
|
||||
- Источник: `/lampac/module/LampaWeb/plugins/extensions.json` — переопределён mount из `./plugins/extensions.json`. Править хост-файл, в контейнер не залезать.
|
||||
- `{localhost}` в ссылках store сервер заменяет своим адресом при выдаче.
|
||||
- Статика: `/lampac/plugins/` **не раздаётся** (проверено: файл → 404), корень `wwwroot` — раздаётся. Все плагины каталога — **локальный mirror** в `./plugins/`, смонтирован **одним directory-mount** в `/lampac/wwwroot/plugins/` → ссылки store `{localhost}/plugins/<имя>.js`. Не монтировать каталог целиком в корень `wwwroot` — скроется веб-клиент `lampa-main`.
|
||||
- Обновление плагина: исходный URL — в `plugins/sources.json`; скачать, перезаписать файл, перезапуск не нужен (статика).
|
||||
- При проверке скачанного плагина отличать: (а) реальный код, (б) лоадер `putScriptAsync` (абсолютные URL — безопасен локально; host от currentScript — **требуется патч**, см. showy.js), (в) пустой stub/placeholder (мёртвый плагин, из каталога удалять).
|
||||
- **Кеш-ловушка**: ответ `/extensions` (и других LampaWeb-маршрутов) кешируется на диск в `/lampac/cache/static/<bucket>/<id>.json` (named volume `lampac_cache`); кеш переживает `docker compose restart`. После правки store: `docker exec lampac rm -rf /lampac/cache/static/59` (bucket `/extensions`), перезапуск не нужен. Заголовок `X-StatiCache-Status: HIT/MISS` показывает состояние.
|
||||
|
||||
### 5. AdminPanel API из скриптов (root-авторизация)
|
||||
|
||||
- Root-пароль = содержимое `config/passwd`. Механизм: cookie `accspasswd=<пароль>` (таков штатный сценарий: страница входа `auth.html` сама кладёт пароль в эту cookie). Заголовок `lcrqpasswd` — для внутренних вызовов, на `/adminpanel/api/*` с ним уходит 302 на `/adminpanel/auth`.
|
||||
- Не передавать root-пароль в query string (предупреждение доков).
|
||||
- Сервер может отвечать 404 вместо 401/403, чтобы не раскрывать админский endpoint.
|
||||
- Основные маршруты: `GET /adminpanel/api/init` (чтение), `POST /adminpanel/api/init` (полная запись), `POST /adminpanel/api/init/section/{key}` (секция), `GET /adminpanel/api/current` (merged-конфиг), `GET /adminpanel/api/users-json`.
|
||||
- Правка секций через API пишется в `init.conf` — watcher подхватит без рестарта (runtime-параметры). `customPlugins` — runtime-параметр: плагин появляется в `/lampainit.js` через ~1 с.
|
||||
|
||||
### 6. Диагностика «модуль не найден»
|
||||
|
||||
Чек-лист (в этом порядке):
|
||||
|
||||
1. `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9118/<маршрут>` — 404 подтверждает, что модуль не загружен.
|
||||
2. `docker logs lampac | grep -E "compilation|loaded module" | grep <Имя>` — скомпилирован ли и загружен ли.
|
||||
3. Манифест: `enable: true`? (в `mods/` для встроенных)
|
||||
4. Фильтры `SkipModules`/`LoadModules` в `config/init.conf`.
|
||||
5. Runtime-секция модуля в `config/init.conf`.
|
||||
6. В логе после старта ожидаются строки `compilation <Имя>`, `configure module: <Имя>` (если есть конфиг-секция), `loaded module: <Имя>`.
|
||||
|
||||
### 7. Команды эксплуатации
|
||||
|
||||
```bash
|
||||
docker compose up -d # запуск / пересоздание (при изменении compose)
|
||||
docker compose restart # после правки init.conf / mods
|
||||
docker compose logs -f # логи
|
||||
docker compose pull && docker compose up -d # обновление (сначала поменять тег в compose)
|
||||
docker exec lampac sh -c '...' # осмотр состояния в контейнере
|
||||
```
|
||||
|
||||
`container_name: lampac` — контейнер один; одновременный запуск нескольких compose-файлов с одним именем невозможен. `docker-compose.override.yml` (gitignore) живёт только на сервере Proxmox и не коммитится.
|
||||
|
||||
### 8. Чего не трогать
|
||||
|
||||
- `config/passwd`, `dlna/` — в .gitignore, не коммитить. `cache`/`database` — **named Docker volumes** (`lampac_cache`, `lampac_database`), в git не попадают. Ловушка `.gitignore`: паттерны без якоря (`cache/`) матчат каталоги на любой глубине и без учёта регистра — из-за этого `mods/Music/Services/Cache/` не попадал в git и ломала Roslyn-компицию на сервере (CS0103, crash-loop). Новые данные/каталоги проверять через `git status`/`git check-ignore -v`.
|
||||
- В named volume данные принадлежат тому, кто их туда положил: после миграции/копии `chown -R 1000:1000` (процесс в контейнере — `lampac:1000`), иначе SQLite «attempt to write a readonly database».
|
||||
Исключение: `cache/static/` (в volume `lampac_cache`) — одноразовый диск-кеш ответов LampaWeb; очистка (`docker exec lampac rm -rf /lampac/cache/static/<bucket>`) — штатное действие после правки store/статических маршрутов (см. 4.1).
|
||||
- Тег образа в `docker-compose.yaml` закреплён **точным digest** (`1.68.0@sha256:752dafd8…`): тег 1.68.0 на ghcr mutable, его переиздавали, и новая сборка ломает Roslyn-компиляцию наших копий `mods/` (пример: `Music` → `CS0103 MusicMetadataCacheService`, crash-loop). Обновление образа = новый digest + пересборка копий `mods/` из нового образа. Не делать «просто» `docker compose pull` без фиксации digest.
|
||||
- `LampaWeb.autoupdate` — веб-клиент Lampa сам обновляется из git (`yumata/lampa`), `wwwroot/lampa-main` в контейнере перезаписывается — туда не править.
|
||||
|
||||
### 9. После изменений в проекте
|
||||
|
||||
Обновлять `README.md` и `AGENT.md` в той же ревизии: структура, включённые модули, секции конфига, новые правила.
|
||||
|
||||
### 10. Безопасность (не публиковать)
|
||||
|
||||
`config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`, `bot_token` в `TelegramBot`, `bot_token` в `TelegramAuthBot`, `mutations_api_secret`. Маршруты `/adminpanel`, `/weblog`, `Tracks`, `Catalog`, `DLNA`, proxy-модули — только для доверенной сети без WAF не открывать.
|
||||
|
||||
**Секреты:** в git `config/init.conf` — секреты **пустые**. Реальные значения — в `config/secrets.env` (gitignored), боевой конфиг — `config/init.custom.conf` (gitignored). Собирается скриптом: `python3 scripts/config-secrets.py apply`. Перед коммитом: `python3 scripts/config-secrets.py check` (должен выдать `секретов в config/init.conf нет — ok`). После `git pull`/смены ветки `apply` запускается автоматически через git-хуки (`scripts/install-git-hooks.sh`).
|
||||
|
||||
### 11. Tg-notify.bot (Telegram-бот)
|
||||
|
||||
- Механика: long polling по `TelegramBot.bot_token`; если токен пуст — polling не стартует (в логе `[TelegramBot] Установите bot_token…`, сервер работает). После правки секции: `docker compose restart lampac`.
|
||||
- **Токен только локально:** в git `config/init.conf` поле пустое (правило 10). Реальный токен живёт в `config/secrets.env`, а `docker-compose.yaml` монтирует `config/init.custom.conf` (собрать: `python3 scripts/config-secrets.py apply`). После `git pull` хук `post-merge` делает `apply` сам; если нет хуков — выполнить вручную. Второй экземпляр на том же токене даёт `Conflict: terminated by other getUpdates request`.
|
||||
- Три подсистемы: трекер серий (Trakt→TMDB), трекер озвучек (параллельно Mirage/Collaps/VideoHub через `lampac_host`: `/lite/mirage`, `/lite/collaps`, `/lite/cdnvideohub`), клиентский плагин `tg-notify.js` (вкладка «TG Подписки», перехват 🔔). Полный разбор — в README.md.
|
||||
- Данные подписок: `database/tgnotify/{users.json,subscriptions.json}` (том `./database/`, в .gitignore). Не «чинить» вручную — это живые данные бота.
|
||||
- Диагностика: `GET /tg-notify.js` (200 без авторизации), `GET /api/tg/voices` (200), `/api/tg/*` остальной — требует `user_uid`. Озвучки могут не работать, если балансеры недоступны (проверить `GET /lite/mirage` — тело `disable`/ошибка, `/lite/collaps` — 503); тогда уведомления приходят только о выходе серий.
|
||||
- `kp_api_key` опционален: без него VideoHub резолвит `kinopoisk_id` только через мост `/externalids` (покрытие ~40%), иначе — молча пропускается, Mirage/Collaps работают.
|
||||
|
||||
### 12. TelegramAuth / TelegramAuthBot (вход через Telegram)
|
||||
|
||||
- Не путать с Tg-notify.bot (уведомления): эта пара — **аутентификация** (вход в Lampa через Telegram вместо пароля), группа Community. Копии в `mods/TelegramAuth/` и `mods/TelegramAuthBot/`, манифесты `enable: true`, из `SkipModules` убраны (у нас `SkipModules: []`).
|
||||
- **Сейчас: API-режим.** `TelegramAuth.enable: false` → `accsdb` не поднимается, гейт неактивен, доступ не режется; работают привязки, `/tg/auth/*`, админ-API. `TelegramAuthBot` — бот **`@GatusMonBot`**, long polling идёт (`Telegram API OK`).
|
||||
- Гейт (`LampaWeb.telegramAuthGate`) активен только при `accsdb.enable: true` + `telegramAuthGate.enabled: true` + непустом `botUsername`, иначе тихий откат на `deny.js`. Включать **только вместе с `accsdb`**, и сначала связать свои устройства через бота — иначе текущие UID получат отказ.
|
||||
- `mutations_api_secret` одинаковый в обоих модулях, ненулевой; `bot_token` — в `init.conf`, **не коммитить** (правило 10): в git оба поля пустые.
|
||||
- `owner_telegram_ids: [143672]` → владелец создаётся как `admin` при старте. `auto_provision_users: true` + `auto_provision_activate_immediately: false` → новые привязки ждут одобрения (`/users` в боте, кнопки Принять/Отклонить).
|
||||
- Данные: `database/tgauth/{users.json,admins.json,user_langs.json}` (том `lampac_database`, владелец `1000:1000`). Живые данные — не «чинить» руками.
|
||||
- Проверка: `GET /tg/auth/status?uid=x` → 200; `GET /tg/auth/admin/users` → 401 без заголовка, 200 с `X-TelegramAuth-Mutations-Secret`.
|
||||
- **Контейнер нестабилен / Chromium падает** — проверить `shm_size: 1024mb` в compose (обязательно для Playwright).
|
||||
- **Пути томов** — в этой ветке compose использует абсолютные пути NAS (`/volume1/docker/lampac/...`, `/volume4/Torrents`); при переносе на другой хост их обязательно заменить. Относительные пути — в ветке `main`.
|
||||
- **Не стартует после правки init.conf** — проверить JSON: `python3 -m json.tool config/init.conf`; бэкапы в `/lampac/database/backup/init/`.
|
||||
- **Модуль не загрузился** — смотреть `docker compose logs lampac | grep -E "skip compilation|loaded module"`; для модулей с `enable: false` в образе проверить manifest-оверрайд и его монтирование в compose.
|
||||
- **Tg-notify молчит** — нужен `bot_token` в секции `TelegramBot` init.conf (без него long polling не стартует, в логе предупреждение).
|
||||
- **Занят порт 9118** — `lsof -i :9118` или сменить порт в `init.conf` (`listen.port`) и в `ports` compose-файла одновременно.
|
||||
- **Сеть** — в compose нет фиксированной сети/subnet; используется дефолтная сеть проекта (`lampac_default`).
|
||||
- **Обновление сломало конфиг** — свериться с https://github.com/lampac-nextgen/lampac/blob/main/config/example.init.conf (новые ключи добавляются в стартовый конфиг).
|
||||
|
||||
@@ -1,343 +1,240 @@
|
||||
# Lampac NextGen — локальный Docker-развёртывание
|
||||
# Lampac NextGen — self-hosted backend для Lampa (Docker, Synology NAS)
|
||||
|
||||
Локальное развёртывание [Lampac NextGen](https://github.com/lampac-nextgen/lampac) — самохостируемого бекенда для Lampa — в Docker. Официальная документация: [docs.lampac.dev](https://docs.lampac.dev).
|
||||
Локальное развёртывание **[Lampac NextGen](https://github.com/lampac-nextgen/lampac)** — активного форка оригинального Lampac, чей основной репозиторий больше не поддерживается.
|
||||
|
||||
## Быстрый старт
|
||||
> **Ветка `synology`** — вариант для Synology NAS: в `docker-compose.yaml` используются **абсолютные пути** томов (`/volume1/docker/lampac/...`), а не относительные, как в ветке `main`.
|
||||
|
||||
```bash
|
||||
docker compose up -d # первый запуск
|
||||
docker compose logs -f # логи (строки compilation / configure module / loaded module)
|
||||
docker compose restart # после правки config/init.conf или манифестов в mods/
|
||||
```
|
||||
## Почему этот форк
|
||||
|
||||
- Веб-клиент Lampa: **http://localhost:9118**
|
||||
- Админ-панель: **http://localhost:9118/adminpanel** (root-пароль из `config/passwd`)
|
||||
- TorrServer-прокси: `http://localhost:9118/ts/*` (порт TorrServer — 9085)
|
||||
Оригинальный Lampac (`Mirrowol/Lampac`) давно не обновляется. Из форков наибольшую популярность набрал **lampac-nextgen/lampac**:
|
||||
|
||||
По умолчанию сервер слушает порт **9118**; если порт занят — меняй `listen.port` в `config/init.conf` до первого запуска.
|
||||
| Показатель | Значение |
|
||||
| --- | --- |
|
||||
| Репозиторий | https://github.com/lampac-nextgen/lampac |
|
||||
| Stars / Forks | ~347 / ~80 (самый популярный форк Lampac) |
|
||||
| Активность | ежедневные коммиты, релизы (последний `v1.61.2` — 30.09.2026) |
|
||||
| Стек | ASP.NET Core, .NET 10, AGPL-3.0 |
|
||||
| Docker-образ | `ghcr.io/lampac-nextgen/lampac` (amd64 + arm64) |
|
||||
| Документация | https://docs.lampac.dev |
|
||||
| Чат | https://t.me/LampacTalks/13998 |
|
||||
|
||||
## Первая установка: SQLite не может создать базу в named volume
|
||||
|
||||
**Симптом.** Свежий `docker compose up -d` уходит в crash-loop (`Restarting (139)`, healthcheck `unhealthy`), в логах:
|
||||
|
||||
```
|
||||
Unhandled exception. Microsoft.Data.Sqlite.SqliteException (0x80004005):
|
||||
SQLite Error 14: 'unable to open database file'.
|
||||
at Microsoft.Data.Sqlite.SqliteConnection.Open()
|
||||
at Microsoft.EntityFrameworkCore.Sqlite.Storage.Internal.SqliteDatabaseCreator.Create()
|
||||
at Microsoft.EntityFrameworkCore.RelationalDatabaseCreator.EnsureCreated()
|
||||
at Core.Program.Run(String[] args)
|
||||
```
|
||||
|
||||
**Причина.** Процесс в контейнере работает от uid `1000` (пользователь образа `lampac`), а Docker при первом создании named volume делает его владельцем `root:root`. Смонтированный каталог `/lampac/database` поэтому недоступен на запись для `lampac`, `sqlite3` получает `EACCES` и приложение падает сразу после `EnsureCreated()` — база не создаётся ни разу, поэтому изнутри контейнера это не лечится.
|
||||
|
||||
Та же первопричина у второй ошибки первого запуска:
|
||||
|
||||
```
|
||||
System.UnauthorizedAccessException: Access to the path '/lampac/passwd' is denied.
|
||||
```
|
||||
|
||||
`config/passwd` монтируется файлом и тоже должен принадлежать uid `1000`.
|
||||
|
||||
**Лечение** — на хосте, от root:
|
||||
|
||||
```bash
|
||||
# тома (первый запуск или миграция/копия томов)
|
||||
chown -R 1000:1000 /var/lib/docker/volumes/lampac_database/_data \
|
||||
/var/lib/docker/volumes/lampac_cache/_data
|
||||
|
||||
# файл пароля (владелец 1000, права 600 — читает только процесс контейнера)
|
||||
chown 1000:1000 config/passwd && chmod 600 config/passwd
|
||||
|
||||
docker compose up -d # или docker compose restart, если контейнер уже создан
|
||||
```
|
||||
|
||||
**Проверка:**
|
||||
|
||||
```bash
|
||||
docker compose ps # Up (healthy)
|
||||
docker logs --since 2m lampac | grep -ci unhandled # 0
|
||||
ls -ln /var/lib/docker/volumes/lampac_database/_data/Sisi.sql # uid/gid 1000
|
||||
```
|
||||
|
||||
> После лечения в `lampac_database` должны появиться `Sisi.sql`, `Sync.sql`, `TimeCode.sql`, `tgnotify/`, `tracks/`, `storage/` — это и есть признак, что базы созданы.
|
||||
|
||||
Проверить uid процесса в контейнере: `docker exec lampac id` → `uid=1000(lampac)`.
|
||||
|
||||
Правило: **любой bind-mount файл/каталог, в который пишет контейнер, должен принадлежать `1000:1000`** (см. `.gitignore` для `config/passwd`, `dlna/`, `lampa-main/`).
|
||||
|
||||
## Секреты (вынесены из git)
|
||||
|
||||
Модули `TelegramBot`, `TelegramAuthBot`, `TelegramAuth` читают `bot_token` и `mutations_api_secret` из конфига. В репозитории эти поля **всегда пустые** — реальные значения живут в файле, который не коммитится.
|
||||
|
||||
| Файл | В git? | Назначение |
|
||||
|---|---|---|
|
||||
| `config/init.conf` | ✅ | Шаблон конфига, секреты **пустые**. Именно его редактируете для обычных настроек (`listen`, `SkipModules`, `initPlugins`, модули). |
|
||||
| `config/secrets.env` | ❌ .gitignore | Токены ботов и `mutations_api_secret`. `TELEGRAM_NOTIFY_BOT_TOKEN`, `TELEGRAM_AUTH_BOT_TOKEN`, `TELEGRAM_AUTH_MUTATIONS_SECRET`. |
|
||||
| `config/init.custom.conf` | ❌ .gitignore | Боевой конфиг, который монтируется в контейнер. Собирается из шаблона + `secrets.env` скриптом. |
|
||||
| `scripts/config-secrets.py` | ✅ | Собирает/разбирает боевой конфиг: `apply`, `export`, `check`, `status`. |
|
||||
| `scripts/install-git-hooks.sh` | ✅ | Устанавливает хуки: `post-merge`/`post-checkout` → `apply`, `pre-commit` → `check`. |
|
||||
| `docker-compose.yaml` | ✅ | Монтирует `./config/init.custom.conf:/lampac/init.conf` (не `init.conf`). |
|
||||
|
||||
**Рабочий процесс:**
|
||||
|
||||
```bash
|
||||
# первый раз / после git pull:
|
||||
python3 scripts/config-secrets.py apply # init.custom.conf = шаблон + secrets.env
|
||||
docker compose up -d # рекреация из-за нового mount
|
||||
|
||||
# правки в init.conf:
|
||||
# 1) правите config/init.conf (шаблон)
|
||||
# 2) python3 scripts/config-secrets.py apply -> обновляет init.custom.conf
|
||||
# 3) docker compose restart lampac
|
||||
|
||||
# перед коммитом:
|
||||
python3 scripts/config-secrets.py export # секреты -> пустые в шаблоне
|
||||
git add config/init.conf
|
||||
git commit -m "..."
|
||||
```
|
||||
|
||||
**Git-хуки (рекомендуется):** `bash scripts/install-git-hooks.sh` — после `git pull`/смены ветки `apply` запустится сам, а `pre-commit` не даст случайно закоммитить секреты.
|
||||
|
||||
> `docker-compose.override.yml` (в .gitignore) — серверный override для Proxmox (том DLNA -> `/mnt/torrents`), живёт только на сервере.
|
||||
Что умеет: агрегация ссылок с 70+ VOD/аниме/18+ источников, встроенный TorrServer, DLNA, JacRed, транскодинг (GStreamer/FFmpeg), синхронизация закладок, хостинг Lampa UI, Telegram-уведомления, WAF и многое другое.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
| Путь | Назначение |
|
||||
|---|---|
|
||||
| `docker-compose.yaml` | Основной сервис (образ `ghcr.io/lampac-nextgen/lampac:1.68.1`) |
|
||||
| `docker-compose.override.yml` | Серверный override (Proxmox: том DLNA -> `/mnt/torrents`), в .gitignore |
|
||||
| `config/init.conf` | Шаблон конфига (JSON, в git, секреты **пустые**) |
|
||||
| `config/init.custom.conf` | Боевой конфиг (в .gitignore, монтируется в `/lampac/init.conf`) |
|
||||
| `config/secrets.env` | Токены ботов и `mutations_api_secret` (в .gitignore) |
|
||||
| `config/passwd` | Пароль root для `/adminpanel` и `/weblog` (в .gitignore) |
|
||||
| `lampa-main/` | Веб-клиент Lampa (bind-mount в `/lampac/wwwroot/lampa-main`, в .gitignore, качается LampaCron) |
|
||||
| volume `lampac_cache` | Кеш (`/lampac/cache`) — Docker named volume |
|
||||
| volume `lampac_database` | SQLite-базы Sync, TimeCode, tg-notify, tracks, storage (`/lampac/database`) — Docker named volume |
|
||||
| `dlna/` | Локальные медиа для DLNA (`/lampac/data/dlna`) |
|
||||
| `plugins/extensions.json` | Каталог плагинов для веб-клиента (раздел «Плагины»), отдаётся через `/extensions` |
|
||||
| `mods/<Имя>/` | Пользовательские копии встроенных модулей с `enable: true` |
|
||||
```text
|
||||
/volume1/docker/lampac/ # корень проекта на NAS
|
||||
├── docker-compose.yaml # compose-файл (порт 9118, абсолютные пути томов)
|
||||
├── config/
|
||||
│ ├── init.conf # Конфигурация сервера (JSON, hot-reload)
|
||||
│ └── passwd # Пароль root (СЕКРЕТ, не коммитить)
|
||||
├── modules/ # Overridе-файлы, монтируемые поверх файлов образа
|
||||
│ ├── Tracks/manifest.json
|
||||
│ ├── Music/manifest.json
|
||||
│ ├── Tg-notify.bot/manifest.json
|
||||
│ ├── AdminPanel/manifest.json
|
||||
│ └── LampaWeb/plugins/extensions.json # каталог магазина плагинов (+DLNA)
|
||||
├── cache/ # Кеш (→ /lampac/cache)
|
||||
├── database/ # SQLite-БД: Sync, TimeCode, SISI (→ /lampac/database)
|
||||
├── README.md # Эта документация
|
||||
├── AGENT.md # Инструкции для AI-агента
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
> Веб-клиент `lampa-main` **нет в образе**: `LampaCron` качает его с GitHub при старте, и он живёт только в writable-слое контейнера. При пересоздании контейнера (`docker compose up -d`) клиент исчезает, если не смонтирован как bind-mount. См. раздел «Секреты» и `scripts/restore-lampa-main.sh`.
|
||||
Тома, заданные в `docker-compose.yaml` (вне репозитория):
|
||||
|
||||
## Включённые модули из `mods/`
|
||||
| Путь хоста (NAS) | В контейнере | Доступ |
|
||||
| --- | --- | --- |
|
||||
| `/volume1/docker/lampac/config/passwd` | `/lampac/passwd` | rw |
|
||||
| `/volume1/docker/lampac/config/init.conf` | `/lampac/init.conf` | ro |
|
||||
| `/volume1/docker/lampac/cache` | `/lampac/cache` | rw |
|
||||
| `/volume1/docker/lampac/database` | `/lampac/database` | rw |
|
||||
| `/volume1/docker/lampac/modules/*/...` | `/lampac/module/...` | ro |
|
||||
| `/volume4/Torrents` | `/lampac/data/dlna` | rw — медиа DLNA |
|
||||
| `/volume1/homes/prodik/NAS/Курсы` | `/lampac/data/dlna/Cources` | ro — подпапка внутри DLNA |
|
||||
|
||||
Встроенные модули с `"enable": false` в `manifest.json` внутри образа **нельзя включать правкой файла в контейнере** — файлы пропадут при обновлении. Механика по официальной документации ([модули Lampac](https://docs.lampac.dev/modules/overview), [пользовательские модули](https://docs.lampac.dev/maintenance/custom-modules)):
|
||||
## Быстрый старт
|
||||
|
||||
1. Скопировать модуль из образа в `mods/<Имя>/` на хосте:
|
||||
```bash
|
||||
docker cp lampac:/lampac/module/<Имя> "mods/<Имя>"
|
||||
```
|
||||
2. В `mods/<Имя>/manifest.json` поставить `"enable": true`.
|
||||
3. Добавить том в `docker-compose.yaml`:
|
||||
```yaml
|
||||
- ./mods/<Имя>:/lampac/mods/<Имя>
|
||||
```
|
||||
4. При необходимости добавить секцию модуля в `config/init.conf` (runtime-флаг).
|
||||
5. `docker compose up -d` (при добавлении тома) или `restart` (при правке манифеста с `dynamic: true`).
|
||||
Требования: Docker 24+ с Compose v2 (проверка: `docker compose version`).
|
||||
|
||||
### Сейчас подключены
|
||||
```bash
|
||||
# 1. Запуск
|
||||
docker compose up -d
|
||||
|
||||
| Модуль | Что даёт | Секция в init.conf |
|
||||
|---|---|---|
|
||||
| `Tracks` | `/tracks.js`, `/ffprobe` — субтитры и дорожки | `"Tracks": { "enable": true }` |
|
||||
| `AdminPanel` | `/adminpanel` — редактор init.conf, current.conf, users.json | `"AdminPanel": { "enable": true }` |
|
||||
| `Tg-notify.bot` | `/tg-notify.js`, `/api/tg/*` — уведомления о новых сериях в Telegram | секция `"TelegramBot"` |
|
||||
| `TelegramAuth` | `/tg/auth/*` — привязка UID устройства к учётке Telegram, HTTP API статуса | секция `"TelegramAuth"` (`enable: false` → без accsdb-гейта) |
|
||||
| `TelegramAuthBot` | `@GatusMonBot` — long-polling бот привязки и админ-команды | секция `"TelegramAuthBot"` |
|
||||
# 2. Проверка
|
||||
curl -fsS "http://localhost:9118/version?type=hash" # должен вернуть hash
|
||||
curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:9118/online.js # 200
|
||||
|
||||
Клиентский плагин `tg-notify.js` зарегистрирован в `LampaWeb.customPlugins`; `{localhost}` в URL автоматически подставляется сервером.
|
||||
# 3. Логи / статус
|
||||
docker compose logs -f lampac
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
> **Модуль `Music` удалён из `mods/`** (коммит `remove Music`): в образе у него `manifest.enable: false`, поэтому `/music.js` → 404, секция `"Music"` из `init.conf` убрана, плагин из `customPlugins` убран. Вернуть музыку — той же механикой: скопировать `lampac:/lampac/module/Music` в `mods/Music`, поставить `enable: true`, вернуть секцию и плагин.
|
||||
## Доступ
|
||||
|
||||
### Ещё можно включить тем же способом
|
||||
| Сервис | URL |
|
||||
| --- | --- |
|
||||
| Веб-интерфейс (Lampa UI) | http://localhost:9118/ |
|
||||
| Плагин Online для Lampa | http://localhost:9118/online.js |
|
||||
| Плагин SISI (18+) | http://localhost:9118/sisi.js |
|
||||
| Плагин DLNA для Lampa | http://localhost:9118/dlna.js |
|
||||
| AdminPanel (вход) | http://localhost:9118/adminpanel/auth |
|
||||
| Проверка версии | http://localhost:9118/version?type=hash |
|
||||
|
||||
В образе выключены также: `DatabaseEditor`, `ExternalBind`, `LogUserRequest-Lite`, `Transcoding`, `Music`, `WatchTogether`.
|
||||
Чтобы подключить сервер к Lampa: откройте Lampa → Настройки → «Другие сервисы» (или вставьте адрес вручную) → `http://IP_МАШИНЫ:9118`.
|
||||
|
||||
Для доступа с других устройств локальной сети используйте LAN-IP машины, например `http://192.168.x.x:9118` (порт уже проброшен на `0.0.0.0`).
|
||||
|
||||
## Конфигурация
|
||||
|
||||
- `config/init.conf` — JSON. После правки: `docker compose restart`. Следи за валидностью JSON (лишняя запятая ломает парсинг).
|
||||
- `BaseModule.SkipModules` / `BaseModule.LoadModules` — фильтры загрузки модулей (имя, группа, regex). `SkipModules` имеет приоритет над `LoadModules`.
|
||||
- `LampaWeb.initPlugins` — флаги встроенных плагинов веб-клиента; `LampaWeb.customPlugins` — дополнительные плагины (`status: 1`).
|
||||
- `LampaWeb.autoupdate: true` — Lampa Web-клиент автообновляется из `yumata/lampa` (раз в 90 минут).
|
||||
### `config/init.conf`
|
||||
|
||||
## Установка плагинов по ссылке через веб (AdminPanel)
|
||||
Основной конфиг (JSON). **Перечитывается автоматически без перезапуска** контейнера (watcher каждую секунду, бэкапы — внутри контейнера в `/lampac/database/backup/init/`). После правки достаточно сохранить файл.
|
||||
|
||||
Список плагинов, который сервер раздаёт всем клиентам через `/lampainit.js` и `/on.js`, собирается по `LampaWeb.initPlugins` + `LampaWeb.customPlugins`. Watcher перечитывает `init.conf` без перезапуска (интервал ~1 с, runtime-параметры применяются на лету).
|
||||
Ключевые разделы:
|
||||
|
||||
1. Открой **http://localhost:9118/adminpanel**, введи root-пароль (содержимое `config/passwd`) — пароль сохраняется в cookie `accspasswd` (штатный механизм root-авторизации).
|
||||
2. В редакторе `init.conf` открой секцию **`LampaWeb`** → **`customPlugins`** и добавь запись:
|
||||
```json
|
||||
{ "url": "https://example.com/plugin.js", "status": 1, "name": "ИмяПлагина" }
|
||||
```
|
||||
Поддерживается плейсхолдер `{localhost}` — сервер подставит свой адрес.
|
||||
3. Сохрани — перезапуск не нужен. Проверка: `curl -s http://localhost:9118/lampainit.js | grep <имя>`.
|
||||
|
||||
Нюансы:
|
||||
- `status: 1` — активная запись.
|
||||
- Family-dedup: `name.js` и `name/js/{token}` считаются одним плагином; если в registry клиента уже есть один вариант, второй из серверного списка не добавляется.
|
||||
- Внешние URL должны быть доступны из браузеров клиентов и отдавать CORS-заголовки.
|
||||
- Альтернатива без сервера: вставить ссылку напрямую в веб-клиенте Lampa (настройки → «Плагины») — тогда плагин живёт только в localStorage этого браузера и грузится браузером напрямую.
|
||||
|
||||
API (для скриптов): `GET/POST /adminpanel/api/init`, `POST /adminpanel/api/init/section/{key}` с cookie `accspasswd=<root-пароль>`.
|
||||
|
||||
## Каталог плагинов (раздел «Плагины» в веб-клиенте)
|
||||
|
||||
В веб-клиенте Lampa раздел «Плагины» грузит каталог (store) с маршрута **`/extensions`** (включается при `LampaWeb.initPlugins.pirate_store: true`). Содержимое берётся из файла `LampaWeb/plugins/extensions.json` модуля — у нас он переопределён файловым mount из `./plugins/extensions.json` (см. `docker-compose.yaml`). Плейсхолдер `{localhost}` в ссылках сервер заменяет своим адресом.
|
||||
|
||||
**Все плагины каталога размещены локально** (mirror): `.js`-файлы лежат в `./plugins/`, каталог целиком смонтирован в `/lampac/wwwroot/plugins/` (одна строка в compose) и ссылки в каталоге ведут на `{localhost}/plugins/<имя>.js`. (Целиком в корень `wwwroot` монтировать нельзя — перекроется веб-клиент `lampa-main`.) Сами файлы плагинов всегда локальные; исключение — плагин-лоадеры, подгружающие основной код с внешнего хоста: «Клубничка 18+» (rc.bwa.ad) и Showy (showy.online/online.js — файл локально патчен под абсолютный URL, иначе он бы подгружал `/online.js` с нашего сервера). Остальные скачаны целиком (для pubtorr / torrentmanager / td скачано тело, а не лоадер).
|
||||
|
||||
- `plugins/sources.json` — карта «локальный файл → исходный URL». Для обновления плагина: скачать файл по исходному URL, перезаписать локальный, перезапуск не нужен.
|
||||
- `plugins/modss.js` — локальная patched-копия MODS's (заменён вызов `Lampa.Utils.isPWA()`), раздаётся как `/plugins/modss.js`, но из каталога исключена (upstream мёртв: загрузчик отвечает stub-ошибкой «Используйте: lampa.mx»). Вернуть карточку можно, добавив запись с `link: "{localhost}/plugins/modss.js"`.
|
||||
|
||||
Удалены из каталога (мёртвы upstream, проверено по факту выдачи):
|
||||
- «MODS's» (`lampa.stream/modss`) — загрузчик отвечает stub-ошибкой;
|
||||
- «Отзывы и рецензии» (`lampaplugins.github.io/store/o.js`) — файл заменён автором на пустой stub;
|
||||
- «Вторая кнопка Торренты», «Поиск локального TorrServer», «Горячие кнопки», «Золотая Тема», «Тип управления», «Старый стиль», «Мультфильмы» — публичных копий нет;
|
||||
- «Телевидение by Skaz» / «ТВ by skaz» / «ТВ by skaz 2.0» — файлы заменены placeholder-заглушками;
|
||||
- «Импорт/Экспорт закладок, истории» (`skaz.tv/export.js`) — 404.
|
||||
|
||||
Важный нюанс — **диск-кеш ответа**: маршруты LampaWeb кешируют ответы в `/lampac/cache/static/<bucket>/<id>.json` (named volume `lampac_cache`), кеш переживает рестарт контейнера. После правки `extensions.json` для мгновенного применения удалить закешированную запись:
|
||||
|
||||
```bash
|
||||
docker exec lampac rm -rf /lampac/cache/static/59 # bucket /extensions; либо весь /lampac/cache/static
|
||||
```
|
||||
|
||||
Перезапуск контейнера при этом **не требуется** (маршрут читает файл при каждом запросе, если нет кеш-записи).
|
||||
|
||||
## Tg-notify.bot: Telegram-бот уведомлений о новых сериях
|
||||
|
||||
Фоновый Telegram-бот (long polling): пользователь подписывается на сериал прямо из карточки Lampa, бот отслеживает выход новых эпизодов и появление выбранной озвучки и шлёт в Telegram уведомление с обложкой, номером серии и описанием из TMDB.
|
||||
|
||||
Модуль объединяет три подсистемы:
|
||||
1. **Трекер новых серий** — Trakt API (если задан `trakt_client_id`), иначе TMDB.
|
||||
2. **Трекер озвучек** — параллельный опрос встроенных балансиров **Mirage**, **Collaps** и **VideoHub** через `lampac_host` (`/lite/mirage`, `/lite/collaps`, `/lite/cdnvideohub`); берётся максимальный эпизод из всех источников.
|
||||
3. **Клиентский плагин** `tg-notify.js` — вкладка «TG Подписки», перехват кнопки 🔔 в карточке, меню выбора озвучки.
|
||||
|
||||
### Как это работает (поток)
|
||||
|
||||
1. **Привязка аккаунта** — во вкладке «TG Подписки» в Lampa выдаётся deep-link `/start link_{uid}` (для UID вида email — `/start link64_{base64url(uid)}`). После `/start` бот сохраняет связку `chat_id ↔ lampac_uid` в `database/tgnotify/users.json`; подписки становятся общими для всех устройств этого аккаунта.
|
||||
2. **Подписка** — в карточке сериала кнопка 🔔 перехватывается плагином, показывается меню озвучек (`GET /api/tg/voices`), выбор уходит на `POST /api/tg/subscribe` и сохраняется в `subscriptions.json`.
|
||||
3. **Фоновый цикл `CheckAll`** — раз в `check_interval_minutes` (первый прогон — через 2 минуты после старта, вручную — `/check`): новые серии через Trakt→TMDB; если выбрана конкретная озвучка — параллельно Mirage + Collaps + VideoHub.
|
||||
4. **Уведомление** — сообщение вида:
|
||||
|
||||
```
|
||||
🎬 Название
|
||||
🎙 Озвучка
|
||||
📺 S05E08 — Название эпизода
|
||||
|
||||
Описание (до 300 символов)
|
||||
```
|
||||
|
||||
с обложкой кадра из TMDB (`still_path`), если есть.
|
||||
|
||||
### Конфигурация (секция `TelegramBot` в `config/init.conf`)
|
||||
|
||||
| Поле | Назначение |
|
||||
|---|---|
|
||||
| `bot_token` | Токен от [@BotFather](https://t.me/BotFather). **Обязателен** — без него long polling не стартует (в логе предупреждение, сервер работает) |
|
||||
| `tmdb_api_key` | Ключ TMDB v3: обложки, описания эпизодов, определение актуального сезона |
|
||||
| `trakt_client_id` | Client ID Trakt; основной источник новых серий (если пуст — только TMDB) |
|
||||
| `lampac_host` | Базовый URL Lampac для запросов к балансерам; в Docker — `http://127.0.0.1:9118` |
|
||||
| `lampac_token` | Токен `accsdb`; обязателен, если включена защита (иначе балансеры вернут ошибку входа) |
|
||||
| `kp_api_key` | Ключ kinopoiskapiunofficial.tech — резолвинг `tmdb_id → kinopoisk_id` по названию для VideoHub (опционально; без него покрытие ~40% через мост `/externalids`) |
|
||||
| `check_interval_minutes` | Период фоновой проверки (дефолт 60) |
|
||||
| `tmdb_lang` | Язык метаданных TMDB (дефолт `ru-RU`, фоллбэк `en-US`) |
|
||||
| `data_dir` | Каталог `users.json`/`subscriptions.json` (дефолт `database/tgnotify` — у нас это том `./database/`, переживает рестарты) |
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
1. [@BotFather](https://t.me/BotFather) → `/newbot` → получить токен.
|
||||
2. `config/init.conf` → `TelegramBot.bot_token` = токен.
|
||||
3. `docker compose restart lampac` (по желанию — заполнить `tmdb_api_key` и `trakt_client_id`).
|
||||
4. В Lampa (http://localhost:9118) вкладка «TG Подписки» → deep-link → `/start` у бота.
|
||||
5. В карточке сериала — 🔔, выбрать озвучку (или «без озвучки» — уведомление о самом факте выхода серии).
|
||||
|
||||
### Команды бота
|
||||
|
||||
| Команда | Действие |
|
||||
|---|---|
|
||||
| `/start` | Приветствие; `/start link_{uid}` — привязка аккаунта |
|
||||
| `/list` | Список активных подписок |
|
||||
| `/check` | Принудительная проверка в фоне |
|
||||
| `/help` | Справка |
|
||||
| `/unlink` | Отвязать аккаунт |
|
||||
|
||||
### HTTP API
|
||||
|
||||
| Метод | Маршрут | Назначение |
|
||||
|---|---|---|
|
||||
| `GET` | `/tg-notify.js` | Раздача клиентского плагина (AllowAnonymous) |
|
||||
| `POST` | `/api/tg/subscribe` | Подписка на сериал/озвучку |
|
||||
| `POST` | `/api/tg/unsubscribe` | Отписка |
|
||||
| `GET` | `/api/tg/status?tmdb_id=` | Статус подписки текущего пользователя |
|
||||
| `GET` | `/api/tg/voices?title=&year=&season=&tmdb_id=` | Доступные озвучки (Mirage + Collaps + VideoHub) |
|
||||
| `GET` | `/api/tg/link` | Deep-link для привязки бота |
|
||||
| `GET` | `/api/tg/subscriptions` | Все подписки пользователя с метаданными |
|
||||
|
||||
Все маршруты, кроме `/tg-notify.js` и `/api/tg/voices`, требуют `requestInfo.user_uid` (вход в аккаунт).
|
||||
|
||||
### Состояние на нашем инстансе
|
||||
|
||||
- Модуль загружен, `/tg-notify.js` → 200, плагин уже в `LampaWeb.customPlugins`, вкладка «TG Подписки» видна; аккаунт привязан (`database/tgnotify/users.json`).
|
||||
- `bot_token` заполняется **только локально**: в git поле пустое (правило 10), токен вставлен в `config/init.conf` на сервере и при работе `@KlinkerZabbixBot` (`[TelegramBot] @KlinkerZabbixBot запущен!`). После любого `git pull`/правки конфига токен нужно вернуть на место — иначе polling не стартует.
|
||||
- Трекинг озвучек зависит от балансиров: на текущий момент `GET /lite/mirage` → тело `disable`, `GET /lite/collaps` → `503` — до тех пор, пока они не будут доступны (или не включится `lampac_token` при защите), бот будет уведомлять только о выходе серий (TMDB/Trakt).
|
||||
|
||||
## TelegramAuth + TelegramAuthBot: вход в Lampa через Telegram
|
||||
|
||||
Пара модулей из группы **Community** — замена пароля аккаунта подтверждением в Telegram (сценарий: ТВ, где вводить пароль неудобно). Не путать с Tg-notify.bot — тот только уведомляет о сериях.
|
||||
|
||||
- **`TelegramAuth`** — серверная часть: хранение пользователей/устройств, HTTP API `/tg/auth/*`, синхронизация UID в `accsdb`; **Telegram Auth Gate** — вместо экрана `deny.js` (модалка пароля) показывает fullscreen-оверлей с UID, кнопкой «Открыть Telegram» и QR-кодом на крупных экранах.
|
||||
- **`TelegramAuthBot`** — long-polling бот: пользователь открывает бота (`/start link_{uid}` или отправляет UID), бот сам вызывает `POST /tg/auth/bind/complete`; есть админ-команды. HTTP UI нет.
|
||||
|
||||
Активация гейта — только при трёх условиях сразу: `accsdb.enable: true` + `LampaWeb.telegramAuthGate.enabled: true` + непустой `botUsername`. Иначе тихий откат на `deny.js`.
|
||||
|
||||
Конфиг (по образцу `init.merge.example.json` модулей):
|
||||
|
||||
```json
|
||||
"TelegramAuth": {
|
||||
"enable": true,
|
||||
"mutations_api_secret": "SECRET"
|
||||
},
|
||||
"TelegramAuthBot": {
|
||||
"enable": true,
|
||||
"bot_token": "ТОКЕН_ОТ_BOTFATHER",
|
||||
"mutations_api_secret": "SECRET"
|
||||
},
|
||||
"LampaWeb": {
|
||||
"telegramAuthGate": {
|
||||
"enabled": true,
|
||||
"botUsername": "имя_бота_без_@",
|
||||
"serviceName": "lampa"
|
||||
}
|
||||
```jsonc
|
||||
{
|
||||
"listen": { "ip": "0.0.0.0", "port": 9118, "scheme": "http" },
|
||||
"BaseModule": {
|
||||
"SkipModules": [/* выключенные модули, см. ниже */]
|
||||
},
|
||||
"LampaWeb": { "initPlugins": { "dlna": true } }, // попадание плагина в список клиента Lampa
|
||||
"TorrServer": { "enable": true, "tsport": 9085, "releases": "latest" },
|
||||
"TelegramBot": { "enable": true, "bot_token": "", "lampac_host": "http://127.0.0.1:9118" },
|
||||
"chromium": { "enable": true }, // Playwright для обхода JS-защит
|
||||
"online": { "name": "Lampac NextGen" },
|
||||
"sisi": { "NextHUB": true }
|
||||
}
|
||||
```
|
||||
|
||||
`mutations_api_secret` должен совпадать в обоих модулях и быть ненулевым — защищает `bind/complete` и бот-админку; вместе с `bot_token` не публиковать. Оба модуля выключены в `base.conf` (`SkipModules`) и в манифестах (`enable: false`).
|
||||
### Текущий набор модулей
|
||||
|
||||
**Состояние у нас:** модули **включены** (копии в `mods/TelegramAuth/` и `mods/TelegramAuthBot/`, манифесты `enable: true`, `SkipModules` пуст), работают в **API-режиме**: `TelegramAuth.enable: false` → `accsdb` не поднимается, гейт неактивен, доступ не режется. Бот `@GatusMonBot` — `Telegram API OK`, long polling идёт; `owner_telegram_ids: [143672]` создаёт владельца ролью `admin` при старте, `auto_provision_users: true` + `auto_provision_activate_immediately: false` — новые привязки висят в pending до одобрения кнопкой у админа. Секрет `mutations_api_secret` (одинаков в обеих секциях) и `bot_token` лежат в `config/init.conf` **только на сервере**.
|
||||
**Выключены** (`BaseModule.SkipModules`): `OnlineAnime`, `OnlineUKR`, `OnlinePaid`, `OnlineGEO`, `Transcoding`, `WebLog`.
|
||||
|
||||
**Порядок включения гейта (когда решим):** поставить `TelegramAuth.enable: true` → в `LampaWeb` секцию `telegramAuthGate` (`enabled: true`, `botUsername: "GatusMonBot"`) → подключить клиентский оверлей (`deny.js` / `telegram_auth_gate.js`) → **сначала связать свои устройства через бота**, иначе текущие UID получат отказ.
|
||||
**Включены дополнительно к стартовому набору:**
|
||||
|
||||
## Обновление Lampac
|
||||
- `Catalog`, `JacRed`, `Sync` (вместе с `Storage`, `SyncEvents`, `TimeCode`), `TorrServer`, `DLNA`, `SISI` — убраны из `SkipModules`.
|
||||
- `Tracks`, `Music`, `Tg-notify.bot`, `AdminPanel` — включены через manifest-оверрайды в `modules/*/manifest.json` (в образе у них `enable: false`; файлы монтируются поверх `/lampac/module/<Name>/manifest.json`).
|
||||
|
||||
Чтобы выключить/включить любой другой модуль — добавьте или уберите его имя в `SkipModules` (имя = имя папки в `/lampac/module/`, включая группы вроде `OnlineAnime`). Изменение применяется после `docker compose restart lampac` (SkipModules не hot-reload). Убрать из списка группу `Sync` целиком отключит и `TimeCode`/`Storage`/`SyncEvents`.
|
||||
|
||||
Список всех модулей и маршрутов: https://docs.lampac.dev/modules/overview
|
||||
|
||||
### TorrServer
|
||||
|
||||
Включён (`TorrServer` в `init.conf`, бинарник сам скачивается с GitHub в `/lampac/data/ts/`). Доступ **только из локальной сети**:
|
||||
|
||||
- внутренний порт TorrServer `9085` **не проброшен на хост** — снаружи он недоступен;
|
||||
- внешний доступ идёт только через прокси `http://LAN_IP:9118/ts/` (порт 9118 в compose);
|
||||
- из интернета сервер не доступен, пока порт 9118 закрыт файрволом/роутером (см. «Безопасность»).
|
||||
|
||||
Пароль (`tspass`) генерируется при старте; Web-UI — `http://LAN_IP:9118/ts/`.
|
||||
|
||||
### Tg-notify (Telegram-бот)
|
||||
|
||||
Модуль `Tg-notify.bot` загружен, но long polling не стартует, пока не задан токен: впишите `bot_token` (от [@BotFather](https://t.me/BotFather)) в секцию `TelegramBot` файла `init.conf` — конфиг подхватится автоматически. Опционально: `tmdb_api_key`, `trakt_client_id`, `kp_api_key` (описание полей — в README модуля).
|
||||
|
||||
### DLNA в интерфейсе Lampa
|
||||
|
||||
Чтобы плагин DLNA появился в Lampa, нужно две вещи:
|
||||
|
||||
1. **Модуль** загружен (уже включён: `loaded module: DLNA`).
|
||||
2. **Флаг `LampaWeb.initPlugins.dlna`** в `init.conf` — именно он решает, попадёт ли DLNA в список плагинов, который Lampac отдаёт клиенту (`/lampainit.js`). Сейчас выставлен `true`; без него Lampa плагин не увидит (модуль работает, а пункт в меню не появляется).
|
||||
|
||||
```jsonc
|
||||
"LampaWeb": { "initPlugins": { "dlna": true } }
|
||||
```
|
||||
|
||||
Аналогично включаются флаги `tracks`, `catalog`, `dorama`, `sync`, `backup`, `watch_together` и др. (список — `LampaWeb/Models/InitPlugins.cs`, дефолты — в `current.conf`).
|
||||
|
||||
После правки перезагрузите страницу Lampa — `lampainit.js` сам добавит плагин (`Lampa.Plugins.add`) и он появится в Настройки → Плагины (со статусом включён). Ручное добавление по ссылке тоже работает: `http://IP_МАШИНЫ:9118/dlna.js`. Прямой UI модуля — `http://IP_МАШИНЫ:9118/dlna`.
|
||||
|
||||
**В магазине плагинов** (Настройки → Плагины/«Магазин») каталог отдаётся из `/extensions` — файла `LampaWeb/plugins/extensions.json` внутри образа, где DLNA по умолчанию нет. Добавлена запись через override: `modules/LampaWeb/plugins/extensions.json` (секция «Lampac» в начале каталога) монтируется поверх образного файла. Магазин кешируется (`Staticache`) — после правки этого файла кеш надо сбросить: удалить `cache/static/63/*` (каталог по хешу URL) или перезапустить контейнер.
|
||||
|
||||
### AdminPanel
|
||||
|
||||
Веб-админка Lampac: правка `init.conf`/`current.conf`, пользователи `users.json`.
|
||||
|
||||
- Вход: `http://IP_МАШИНЫ:9118/adminpanel/auth` — пароль из `config/passwd` (cookie `accspasswd`, живёт 180 дней).
|
||||
- Панель: `http://IP_МАШИНЫ:9118/adminpanel` (без входа — 302 на страницу auth).
|
||||
- Доступ к админке не открывайте в интернет без защиты — см. README модуля AdminPanel.
|
||||
|
||||
### `config/passwd`
|
||||
|
||||
Пароль учётной записи `root` в одну строку (используется для служебных функций и WebLog). Сгенерирован при установке; для смены отредактируйте файл и перезапустите контейнер: `docker compose restart lampac`.
|
||||
|
||||
## Обновление
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Перед обновлением проверить, что в `docker-compose.yaml` раскомментированы нужные тома. Данные сохраняются в примонтированных томах: `config/`, `cache/`, `database/`, `dlna/`, `mods/`, `lampa-main/`.
|
||||
Данные (`init.conf`, `passwd`, `cache/`, `database/`, `modules/`) переживают обновление, т.к. смонтированы томами с абсолютными путями NAS.
|
||||
|
||||
`wwwroot/lampa-main` смонтирован как bind-mount из `./lampa-main/` (см. раздел «Структура проекта»), поэтому переживает рекреацию контейнера. Если директория на хосте пуста — `bash scripts/restore-lampa-main.sh scripts/lampa-main.zip` (архив берётся с https://github.com/yumata/lampa/archive/refs/heads/main.zip).
|
||||
## Перенос на другой сервер
|
||||
|
||||
**Образ закреплён точным digest** (`1.68.0@sha256:752dafd8…`): тег `1.68.0` на ghcr — mutable, его переиздавали, и в новой сборке **копии модулей в `mods/`** (Roslyn компилирует их на каждом старте) перестают собираться — `CS0103` по символам из старого билда и crash-loop сервера. Чтобы обновить образ: взять новый digest, пересобрать копии модулей в `mods/` из нового образа (`docker cp lampac:/lampac/module/<Имя> mods/<Имя>` + вернуть `"enable": true`) и заменить digest в `docker-compose.yaml`. Не делать «просто» `docker compose pull` без фиксации digest.
|
||||
Игнорируемые пути (`.gitignore`): `config/passwd`, `cache/`, `database/`, `.DS_Store`.
|
||||
|
||||
**Копировать обязательно:**
|
||||
|
||||
```bash
|
||||
docker-compose.yaml
|
||||
config/init.conf # вся конфигурация
|
||||
config/passwd # СЕКРЕТ — переносить вне git (scp/rsync)
|
||||
modules/ # override-файлы (manifest ×4, extensions.json)
|
||||
README.md AGENT.md .gitignore
|
||||
```
|
||||
|
||||
**Копировать по желанию** (данные пользователя, иначе создадутся заново):
|
||||
|
||||
```bash
|
||||
database/ # закладки Sync, позиции TimeCode, SISI, библиотека Music
|
||||
```
|
||||
|
||||
**Не копировать** — регенерируется автоматически: `cache/`, содержимое `/lampac/data` внутри контейнера (GeoLite2, ffmpeg/ffprobe, бинарник TorrServer, Lampa UI). Медиа DLNA лежат на томах NAS (`/volume4/Torrents`, `Курсы`) и копируются отдельно по необходимости.
|
||||
|
||||
Порядок переноса: скопировать файлы → **в `docker-compose.yaml` заменить абсолютные пути томов на новые** → `docker compose up -d` → health-check: `curl "http://localhost:9118/version?type=hash"`. Порт 9118 менять не обязательно.
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
docker compose restart lampac # перезапуск
|
||||
docker compose stop lampac # остановить
|
||||
docker compose down # остановить и удалить контейнер (тома сохраняются)
|
||||
docker compose logs -f --tail 100 lampac # логи
|
||||
docker compose exec lampac sh # шелл внутри контейнера
|
||||
docker compose pull && docker compose up -d # обновление до свежего latest
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
По предупреждению официальной документации:
|
||||
- Порт 9118 проброшен на все интерфейсы — при выходе в интернет закройте его файрволом или поднимите reverse proxy с аутентификацией. Внутренний порт TorrServer (9085) не проброшен вовсе: он доступен только контейнеру, внешний доступ к TorrServer идёт исключительно через `/ts/` на 9118.
|
||||
- Модули `DLNA`, `Tracks`, `Transcoding`, `GStreamer`, `Catalog` не экранируют входящие запросы — не открывайте их публично без защиты.
|
||||
- `config/passwd` содержит секрет — файл добавлен в `.gitignore`.
|
||||
|
||||
- Не публикуй в интернет без аутентификации, WAF и ограничения сети: `/adminpanel`, `/weblog`, `/database-editor`, `Tracks`, `Transcoding`, `Catalog`, `DLNA`, proxy-модули.
|
||||
- Нигде не раскрывай `config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`.
|
||||
- **Секреты в git не попадают**: `TelegramBot.bot_token`, `TelegramAuthBot.bot_token`, `mutations_api_secret` хранятся в `config/secrets.env` (в .gitignore). В git `config/init.conf` эти поля всегда пустые. Реальный боевой конфиг — `config/init.custom.conf` (тоже gitignored), собирается скриптом `scripts/config-secrets.py apply` из шаблона + секретов. Перед `git add config/init.conf`: `python3 scripts/config-secrets.py check` → должно быть `check: секретов в config/init.conf нет — ок`.
|
||||
- Для публикации за reverse proxy: [deployment/reverse-proxy](https://docs.lampac.dev/deployment/reverse-proxy) (HTTPS в ответах, реальный IP клиента, WebSocket, SSE, streaming).
|
||||
- `shm_size: 4096mb` — рекомендованный минимум для Playwright/Chromium.
|
||||
## Git-репозиторий
|
||||
|
||||
Код проекта хранится в Gitea: `ssh://git@gitea.prodik.ru:222/prodik/lampac.git`.
|
||||
|
||||
- **`main`** — основная ветка: относительные пути томов (подходит для любого сервера).
|
||||
- **`synology`** (эта ветка) — то же содержимое, но `docker-compose.yaml` адаптирован под Synology NAS: абсолютные пути `/volume1/docker/lampac/...`, тома `/volume4/Torrents` и `Курсы`.
|
||||
|
||||
```bash
|
||||
git remote -v # origin должен указывать на этот репозиторий
|
||||
git add -A # секреты/данные не попадут (.gitignore)
|
||||
git commit -m "описание"
|
||||
git push origin synology
|
||||
```
|
||||
|
||||
В git попадают только: `docker-compose.yaml`, `config/init.conf`, `modules/**`, `README.md`, `AGENT.md`, `.gitignore`. Секреты (`passwd`), `cache/`, `database/` — в `.gitignore`.
|
||||
|
||||
## Ссылки
|
||||
|
||||
- Репозиторий форка: https://github.com/lampac-nextgen/lampac
|
||||
- Документация: https://docs.lampac.dev
|
||||
- Быстрый старт: https://docs.lampac.dev/quickstart
|
||||
- Docker: https://docs.lampac.dev/deployment/docker
|
||||
- Конфигурация: https://docs.lampac.dev/configuration/overview
|
||||
- Релизы: https://github.com/lampac-nextgen/lampac/releases
|
||||
|
||||
+12
-64
@@ -2,18 +2,18 @@
|
||||
"listen": {
|
||||
"version": true,
|
||||
"ip": "0.0.0.0",
|
||||
"port": 80,
|
||||
"port": 9118,
|
||||
"scheme": "http",
|
||||
"localhost": "127.0.0.1"
|
||||
},
|
||||
"BaseModule": {
|
||||
"SkipModules": []
|
||||
},
|
||||
"Tracks": {
|
||||
"enable": true
|
||||
},
|
||||
"AdminPanel": {
|
||||
"enable": true
|
||||
"SkipModules": [
|
||||
"OnlineAnime",
|
||||
"OnlineUKR",
|
||||
"OnlinePaid",
|
||||
"OnlineGEO",
|
||||
"Transcoding"
|
||||
]
|
||||
},
|
||||
"TorrServer": {
|
||||
"enable": true,
|
||||
@@ -23,23 +23,12 @@
|
||||
"TelegramBot": {
|
||||
"enable": true,
|
||||
"bot_token": "",
|
||||
"lampac_host": "http://127.0.0.1:80"
|
||||
"lampac_host": "http://127.0.0.1:9118"
|
||||
},
|
||||
"LampaWeb": {
|
||||
"initPlugins": {
|
||||
"dlna": true,
|
||||
"tracks": true,
|
||||
"sync": true,
|
||||
"backup": true,
|
||||
"Catalog": true
|
||||
},
|
||||
"customPlugins": [
|
||||
{
|
||||
"url": "{localhost}/tg-notify.js",
|
||||
"status": 1,
|
||||
"name": "Tg-notify"
|
||||
}
|
||||
]
|
||||
"dlna": true
|
||||
}
|
||||
},
|
||||
"chromium": {
|
||||
"enable": true,
|
||||
@@ -61,46 +50,5 @@
|
||||
"history": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
"TelegramAuth": {
|
||||
"enable": false,
|
||||
"data_dir": "database/tgauth",
|
||||
"legacy_import_path": "database/legacy_tgauth",
|
||||
"enable_import": true,
|
||||
"enable_cleanup": true,
|
||||
"mutations_api_secret": "",
|
||||
"owner_telegram_ids": [
|
||||
143672
|
||||
],
|
||||
"auto_provision_users": true,
|
||||
"auto_provision_role": "user",
|
||||
"auto_provision_lang": "ru",
|
||||
"auto_provision_expires_days": 0,
|
||||
"auto_provision_activate_immediately": false,
|
||||
"accsdb_sync_group_admin": 100,
|
||||
"accsdb_sync_group_user": 0,
|
||||
"max_active_devices_per_user": 5,
|
||||
"limit_map": [
|
||||
{
|
||||
"pattern": "^/tg/auth",
|
||||
"map": {
|
||||
"limit": 25,
|
||||
"second": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"TelegramAuthBot": {
|
||||
"enable": true,
|
||||
"bot_token": "",
|
||||
"lampac_base_url": "http://127.0.0.1:80",
|
||||
"request_timeout_sec": 10,
|
||||
"service_display_name": "Lampac NextGen",
|
||||
"mutations_api_secret": "",
|
||||
"admin_chat_ids": [],
|
||||
"owner_telegram_ids": [
|
||||
143672
|
||||
],
|
||||
"notify_admins_on_pending_provision": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
+16
-15
@@ -3,23 +3,24 @@ name: lampac
|
||||
|
||||
services:
|
||||
lampac:
|
||||
image: ghcr.io/lampac-nextgen/lampac:1.68.1
|
||||
image: ghcr.io/lampac-nextgen/lampac:latest
|
||||
container_name: lampac
|
||||
ports:
|
||||
- 80:80
|
||||
- 9118:9118
|
||||
restart: unless-stopped
|
||||
shm_size: 4096mb
|
||||
# logging:
|
||||
# options:
|
||||
# tag: "{{.Name}}"
|
||||
volumes:
|
||||
- ./config/passwd:/lampac/passwd
|
||||
- ./config/init.conf:/lampac/init.conf
|
||||
- ./plugins/extensions.json:/lampac/module/LampaWeb/plugins/extensions.json
|
||||
- ./plugins:/lampac/wwwroot/plugins
|
||||
- ./mods:/lampac/mods
|
||||
- cache:/lampac/cache
|
||||
- database:/lampac/database
|
||||
- ./dlna:/lampac/data/dlna
|
||||
- ./data/ts:/lampac/data/ts
|
||||
|
||||
volumes:
|
||||
cache:
|
||||
database:
|
||||
- /volume1/docker/lampac/config/passwd:/lampac/passwd
|
||||
- /volume1/docker/lampac/config/init.conf:/lampac/init.conf
|
||||
- /volume1/docker/lampac/cache:/lampac/cache
|
||||
- /volume1/docker/lampac/database:/lampac/database
|
||||
- /volume1/docker/lampac/modules/Tracks/manifest.json:/lampac/module/Tracks/manifest.json:ro
|
||||
- /volume1/docker/lampac/modules/Music/manifest.json:/lampac/module/Music/manifest.json:ro
|
||||
- /volume1/docker/lampac/modules/Tg-notify.bot/manifest.json:/lampac/module/Tg-notify.bot/manifest.json:ro
|
||||
- /volume1/docker/lampac/modules/AdminPanel/manifest.json:/lampac/module/AdminPanel/manifest.json:ro
|
||||
- /volume1/docker/lampac/modules/LampaWeb/plugins/extensions.json:/lampac/module/LampaWeb/plugins/extensions.json:ro
|
||||
- /volume4/Torrents:/lampac/data/dlna
|
||||
- /volume1/homes/prodik/NAS/Курсы:/lampac/data/dlna/Cources:ro
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>library</OutputType>
|
||||
<IsPackable>true</IsPackable>
|
||||
<RootNamespace>AdminPanel</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,366 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Shared;
|
||||
using Shared.Attributes;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AdminPanel;
|
||||
|
||||
[Authorization(redirectUri: "/adminpanel/auth")]
|
||||
public class AdminPanelController : BaseController
|
||||
{
|
||||
const string InitFile = "init.conf";
|
||||
const string CurrentFile = "current.conf";
|
||||
const string UsersFile = "users.json";
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/adminpanel/auth")]
|
||||
public ActionResult Auth()
|
||||
{
|
||||
var path = Path.Combine(ModInit.modpath, "auth.html");
|
||||
var html = System.IO.File.ReadAllText(path, Encoding.UTF8);
|
||||
return Content(html, "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("/adminpanel")]
|
||||
public ActionResult Index()
|
||||
{
|
||||
var path = Path.Combine(ModInit.modpath, "index.html");
|
||||
var html = System.IO.File.ReadAllText(path, Encoding.UTF8);
|
||||
return Content(html, "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("/adminpanel/api/groups")]
|
||||
public ActionResult Groups()
|
||||
{
|
||||
var current = LoadCurrentRoot();
|
||||
var built = ConfigSectionGroups.Build(current);
|
||||
var json = JsonConvert.SerializeObject(built, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
});
|
||||
return Content(json, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("/adminpanel/api/groups/catalog")]
|
||||
public ActionResult GroupsCatalog()
|
||||
{
|
||||
var built = ConfigSectionGroups.BuildCatalog();
|
||||
var current = LoadCurrentRoot();
|
||||
var inCatalog = ConfigSectionGroups.CatalogRootKeys;
|
||||
var orphans = current.Properties()
|
||||
.Select(p => p.Name)
|
||||
.Where(k => !inCatalog.Contains(k))
|
||||
.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (orphans.Length > 0)
|
||||
built.Add(new GroupDto("other", "Прочее", "Ключи из current, не из каталога групп.", orphans));
|
||||
|
||||
var json = JsonConvert.SerializeObject(built, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
});
|
||||
return Content(json, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
static JObject LoadCurrentRoot()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(CurrentFile))
|
||||
return JObject.Parse(System.IO.File.ReadAllText(CurrentFile, Encoding.UTF8));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
if (CoreInit.CurrentConf != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JObject)CoreInit.CurrentConf.DeepClone();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return new JObject();
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("/adminpanel/api/init")]
|
||||
public ActionResult GetInit()
|
||||
{
|
||||
if (!System.IO.File.Exists(InitFile))
|
||||
return Content("{}", "application/json; charset=utf-8");
|
||||
|
||||
var text = System.IO.File.ReadAllText(InitFile, Encoding.UTF8);
|
||||
return Content(NormalizeJsonText(text), "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("/adminpanel/api/current")]
|
||||
public ActionResult GetCurrent()
|
||||
{
|
||||
if (!System.IO.File.Exists(CurrentFile))
|
||||
{
|
||||
if (CoreInit.CurrentConf != null)
|
||||
return Content(CoreInit.CurrentConf.ToString(Formatting.Indented), "application/json; charset=utf-8");
|
||||
return Content("{}", "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
var text = System.IO.File.ReadAllText(CurrentFile, Encoding.UTF8);
|
||||
return Content(NormalizeJsonText(text), "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("/adminpanel/api/init")]
|
||||
public async Task<IActionResult> SaveInit()
|
||||
{
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
||||
body = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return AdminJsonError(400, "empty body");
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JToken.Parse(body);
|
||||
if (parsed.Type != JTokenType.Object)
|
||||
return AdminJsonError(400, "root must be a JSON object");
|
||||
|
||||
var formatted = ((JObject)parsed).ToString(Formatting.Indented);
|
||||
await WriteInitAtomicAsync(formatted).ConfigureAwait(false);
|
||||
return AdminJsonOk();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return AdminJsonError(400, "invalid json", ex.Message);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return AdminJsonError(500, "failed to write init.conf", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("/adminpanel/api/init/section/{key}")]
|
||||
public async Task<IActionResult> SaveInitSection(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || key.Contains('/') || key.Contains('\\'))
|
||||
return AdminJsonError(400, "invalid section key");
|
||||
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
||||
body = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return AdminJsonError(400, "empty body");
|
||||
|
||||
JToken sectionToken;
|
||||
try
|
||||
{
|
||||
sectionToken = JToken.Parse(body);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return AdminJsonError(400, "invalid json", ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
JObject root;
|
||||
if (System.IO.File.Exists(InitFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
root = JObject.Parse(System.IO.File.ReadAllText(InitFile, Encoding.UTF8));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
root = new JObject();
|
||||
}
|
||||
}
|
||||
else
|
||||
root = new JObject();
|
||||
|
||||
root[key] = sectionToken.DeepClone();
|
||||
var formatted = root.ToString(Formatting.Indented);
|
||||
await WriteInitAtomicAsync(formatted).ConfigureAwait(false);
|
||||
return AdminJsonOk();
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return AdminJsonError(500, "failed to write init.conf", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// tmp+Move — атомарно; на bind mount Move(..., overwrite) часто EBUSY → пишем в init.conf напрямую.
|
||||
static async Task WriteInitAtomicAsync(string formatted)
|
||||
{
|
||||
var tmp = InitFile + ".tmp";
|
||||
await System.IO.File.WriteAllTextAsync(tmp, formatted, Encoding.UTF8).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.File.Move(tmp, InitFile, overwrite: true);
|
||||
}
|
||||
catch (IOException ex) when (IsReplaceTargetBusy(ex))
|
||||
{
|
||||
await System.IO.File.WriteAllTextAsync(InitFile, formatted, Encoding.UTF8).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(tmp))
|
||||
System.IO.File.Delete(tmp);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsReplaceTargetBusy(IOException ex)
|
||||
{
|
||||
for (Exception e = ex; e != null; e = e.InnerException)
|
||||
{
|
||||
if (e.Message != null && e.Message.Contains("busy", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static ContentResult AdminJsonOk()
|
||||
{
|
||||
return new ContentResult
|
||||
{
|
||||
Content = "{\"ok\":true}",
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
StatusCode = 200
|
||||
};
|
||||
}
|
||||
|
||||
static ContentResult AdminJsonError(int status, string error, string detail = null)
|
||||
{
|
||||
var o = new JObject { ["error"] = error };
|
||||
if (!string.IsNullOrEmpty(detail))
|
||||
o["detail"] = detail;
|
||||
return new ContentResult
|
||||
{
|
||||
Content = o.ToString(Formatting.None),
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
StatusCode = status
|
||||
};
|
||||
}
|
||||
|
||||
static string NormalizeJsonText(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JToken.Parse(raw).ToString(Formatting.Indented);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("/adminpanel/api/users-json")]
|
||||
public ActionResult GetUsersJson()
|
||||
{
|
||||
if (!System.IO.File.Exists(UsersFile))
|
||||
return Content("[]", "application/json; charset=utf-8");
|
||||
|
||||
var text = System.IO.File.ReadAllText(UsersFile, Encoding.UTF8);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return Content("[]", "application/json; charset=utf-8");
|
||||
|
||||
return Content(NormalizeJsonText(text), "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("/adminpanel/api/users-json")]
|
||||
public async Task<IActionResult> SaveUsersJson()
|
||||
{
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
||||
body = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return AdminJsonError(400, "empty body");
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JToken.Parse(body);
|
||||
if (parsed.Type != JTokenType.Array)
|
||||
return AdminJsonError(400, "root must be a JSON array", "users.json must be a list of AccsUser objects");
|
||||
|
||||
foreach (var item in (JArray)parsed)
|
||||
{
|
||||
if (item.Type != JTokenType.Object)
|
||||
return AdminJsonError(400, "invalid array item", "each element must be a JSON object");
|
||||
}
|
||||
|
||||
var formatted = ((JArray)parsed).ToString(Formatting.Indented);
|
||||
await WriteUsersAtomicAsync(formatted).ConfigureAwait(false);
|
||||
return AdminJsonOk();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return AdminJsonError(400, "invalid json", ex.Message);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return AdminJsonError(500, "failed to write users.json", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task WriteUsersAtomicAsync(string formatted)
|
||||
{
|
||||
var tmp = UsersFile + ".tmp";
|
||||
await System.IO.File.WriteAllTextAsync(tmp, formatted, Encoding.UTF8).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.File.Move(tmp, UsersFile, overwrite: true);
|
||||
}
|
||||
catch (IOException ex) when (IsReplaceTargetBusy(ex))
|
||||
{
|
||||
await System.IO.File.WriteAllTextAsync(UsersFile, formatted, Encoding.UTF8).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(tmp))
|
||||
System.IO.File.Delete(tmp);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AdminPanel;
|
||||
|
||||
public static class ConfigSectionGroups
|
||||
{
|
||||
public sealed record GroupSpec(string Id, string Title, string Hint, string[] Keys);
|
||||
|
||||
public static readonly GroupSpec[] Catalog =
|
||||
{
|
||||
new("runtime", "Рантайм", "Поля снимка; обычно приходят из current, в init не обязательны.",
|
||||
new[] { "guid", "freeDiskSpace" }),
|
||||
new("listen", "HTTP-сервер (listen)", "Адрес, порт, схема, таймауты, compression.",
|
||||
new[] { "listen" }),
|
||||
new("security", "Безопасность и доступ", "WAF, accsdb, список модулей и middleware ядра.",
|
||||
new[] { "WAF", "accsdb", "BaseModule" }),
|
||||
new("network", "Сеть и прокси", "Прокси исходящих запросов, CORS, доверенные сети.",
|
||||
new[] { "serverproxy", "proxy", "globalproxy", "corsehost", "KnownProxies" }),
|
||||
|
||||
new("pools", "Пулы и служебное", "Буферы, apn, kit.",
|
||||
new[] { "pool", "apn", "kit" }),
|
||||
new("cache-gc", "Кэш и память", "Гибридный кэш, Staticache, настройки GC.",
|
||||
new[] { "cache", "Staticache", "GC" }),
|
||||
new("media", "Изображения и постеры", "Движок картинок, Poster API.",
|
||||
new[] { "imagelibrary", "posterApi" }),
|
||||
|
||||
new("realtime", "WebSocket и RCH", "Нативные сокеты и удалённый хаб.",
|
||||
new[] { "WebSocket", "rch" }),
|
||||
new("browser", "Браузеры (Playwright)", "Chromium / Firefox для автоматизации.",
|
||||
new[] { "chromium", "firefox" }),
|
||||
new("diagnostics", "Логи и диагностика", "Serilog, обработчик исключений, openstat.",
|
||||
new[] { "serilog", "useDeveloperExceptionPage", "exceptionHandlerLogTarget", "exceptionHandlerLogFile", "watcherInit", "openstat" }),
|
||||
|
||||
new("app", "Приложение и оболочка", "online, cub, sisi, реклама, дефолты, omdb.",
|
||||
new[] { "online", "cub", "sisi", "vast", "disableEng", "defaultOn", "omdbapi_key", "overrideResponse" }),
|
||||
new("client", "Клиент Lampa и API", "Оболочка Lampa, cookie, PidTor, TMDB.",
|
||||
new[] { "tmdb", "LampaWeb", "Cookie", "PidTor" }),
|
||||
new("modules", "Модули расширения", "Секции подключаемых модулей в корне конфига.",
|
||||
new[] { "Catalog", "DLNA", "JacRed", "Sync", "TimeCode", "TorrServer", "Tracks", "transcoding", "TmdbProxy", "CubProxy", "WebLog" }),
|
||||
|
||||
new("src-anime", "Источники · аниме", "Онлайн-балансеры аниме и смежные (в т.ч. Kodik).",
|
||||
new[] { "AniLiberty", "AniLibria", "Animebesst", "AnimeGo", "AnimeLib", "AnimeON", "Animevost", "AniMedia", "Dreamerscast", "Kodik", "Mikai", "MoonAnime" }),
|
||||
new("src-embed", "Источники · встраиваемые плееры", "Embed и агрегаторы сторонних плееров.",
|
||||
new[] { "Autoembed", "Hydraflix", "MovPI", "Playembed", "Rgshows", "Smashystream", "Twoembed", "VidLink", "Videasy", "Vidsrc" }),
|
||||
new("src-vod", "Источники · VOD и CDN", "Кино, сериалы, региональные и CDN-провайдеры.",
|
||||
new[]
|
||||
{
|
||||
"Alloha", "Ashdi", "AsiaGe", "BamBoo", "CDNvideohub", "Collaps", "Eneyida", "FanCDN", "Filmix", "FilmixPartner", "FilmixTV", "FlixCDN",
|
||||
"Geosaitebi", "GetsTV", "HDVB", "HdvbUA", "IptvOnline", "iRemux", "Kinobase", "Kinoflix", "Kinogo", "Kinotochka", "Kinoukr", "KinoPub",
|
||||
"LeProduction", "Mirage", "Rezka", "RezkaPrem", "RutubeMovie", "Tortuga", "UaKino", "VideoDB", "Videoseed", "VeoVeo", "Vibix", "VkMovie", "VoKino"
|
||||
}),
|
||||
new("src-adult", "Источники · 18+", "SISI / взрослые сайты.",
|
||||
new[]
|
||||
{
|
||||
"BongaCams", "Chaturbate", "Ebalovo", "Eporner", "HQporner", "PornHub", "PornHubPremium", "Porntrex", "Runetki", "Spankbang", "Tizam",
|
||||
"Xhamster", "Xnxx", "Xvideos", "XvideosRED"
|
||||
}),
|
||||
};
|
||||
|
||||
public static List<GroupDto> Build(JObject currentRoot)
|
||||
{
|
||||
if (currentRoot == null)
|
||||
currentRoot = new JObject();
|
||||
|
||||
var inFile = new HashSet<string>(
|
||||
currentRoot.Properties().Select(p => p.Name),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var assigned = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<GroupDto>();
|
||||
|
||||
foreach (var g in Catalog)
|
||||
{
|
||||
var keys = g.Keys.Where(inFile.Contains).OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
foreach (var k in keys)
|
||||
assigned.Add(k);
|
||||
|
||||
if (keys.Length == 0)
|
||||
continue;
|
||||
|
||||
result.Add(new GroupDto(g.Id, g.Title, g.Hint, keys));
|
||||
}
|
||||
|
||||
var orphans = inFile.Where(k => !assigned.Contains(k)).OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
if (orphans.Length > 0)
|
||||
result.Add(new GroupDto("other", "Прочее", "Ключи из current.conf, не попавшие в каталог (новые модули).", orphans));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<GroupDto> BuildCatalog()
|
||||
{
|
||||
var list = new List<GroupDto>(Catalog.Length);
|
||||
foreach (var g in Catalog)
|
||||
list.Add(new GroupDto(g.Id, g.Title, g.Hint, g.Keys.ToArray()));
|
||||
return list;
|
||||
}
|
||||
|
||||
public static HashSet<string> CatalogRootKeys { get; } = new(
|
||||
Catalog.SelectMany(g => g.Keys),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed record GroupDto(string Id, string Title, string Hint, string[] Keys);
|
||||
@@ -1,18 +0,0 @@
|
||||
using Shared.Models.Module;
|
||||
using Shared.Models.Module.Interfaces;
|
||||
|
||||
namespace AdminPanel;
|
||||
|
||||
public class ModInit : IModuleLoaded
|
||||
{
|
||||
public static string modpath;
|
||||
|
||||
public void Loaded(InitspaceModel baseconf)
|
||||
{
|
||||
modpath = baseconf.path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
# AdminPanel
|
||||
|
||||
Веб-**админка** Lampac: статический UI (**`auth.html`**, **`index.html`**) и JSON API для просмотра и правки **`init.conf`**, снимка **`current.conf`** и пользователей **`users.json`** (режим **accsdb**). Доступ к закрытым маршрутам — после ввода **root-пароля** из файла **`passwd`** (cookie **`accspasswd`**; см. ниже); при отказе **`[Authorization]`** перенаправляет на **`/adminpanel/auth`**.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. Включите модуль в **`manifest.json`** (`"enable": true`). По умолчанию в репозитории стоит **`false`**.
|
||||
2. Соберите и задеплойте сборку так же, как остальные модули (динамический модуль; список файлов — поле **`tree`** в **`manifest.json`**).
|
||||
3. Откройте **`http(s)://<хост>:<порт>/adminpanel/auth`** и войдите **root-паролем из файла `passwd`** в рабочей директории сервера (см. [Вход](#вход)).
|
||||
4. Рабочая панель: **`/adminpanel`**.
|
||||
|
||||
Удобный порт в dev-сборке через Docker часто задаёт compose (например **9118**); точный адрес зависит от **`listen`** в конфигурации Lampac.
|
||||
|
||||
## Вход и сессия
|
||||
|
||||
Страница **`/adminpanel/auth`** сохраняет пароль в cookie **`accspasswd`** (срок **180 дней**, **`path=/`**, **`SameSite=Lax`**, в HTTPS добавляется **`Secure`**).
|
||||
|
||||
Панель дергает API с **`credentials: 'same-origin'`**, поэтому cookie должна быть установлена для того же origin, что и сервер Lampac.
|
||||
|
||||
### Файл `passwd` (root-пароль)
|
||||
|
||||
Вход в админку **не** берётся из **`users.json`** и не совпадает с **`accsdb.shared_passwd`** в **`init.conf`**. Для маршрутов с **`[Authorization]`** ядро сравнивает cookie **`accspasswd`** с **`CoreInit.rootPasswd`**, который читается из файла **`passwd`** в **рабочей директории** процесса (рядом с **`init.conf`**):
|
||||
|
||||
- если **`passwd`** есть при старте — содержимое файла загружается в память, пробелы и переводы строк снимаются;
|
||||
- если файла нет при старте — генерируется случайная строка и записывается в **`passwd`** (имеет смысл сразу задать свой пароль и при необходимости перезапустить процесс).
|
||||
|
||||
В форме входа нужно указывать **именно эту** root-строку.
|
||||
|
||||
Дополнительно ядро ограничивает число **разных** попыток пароля в cookie с одного IP за сутки; после порога ответы к защищённым маршрутам выглядят как **404** (см. **`Core/Middlewares/Accsdb.cs`**).
|
||||
|
||||
Тот же **`rootPasswd`** используется для внутренних вызовов с HTTP-заголовком **`lcrqpasswd`** (это не форма входа в браузере).
|
||||
|
||||
Остальная политика (локальный доступ без проверки, **accsdb** для клиентов Lampa, WAF и т.д.) накладывается **поверх** этой проверки — см. конфигурацию ядра. Не выкладывайте **`passwd`** в Git и не публикуйте админку в открытый интернет без осознанной защиты. В Docker/Kubernetes **`passwd`** обычно монтируют вместе с **`init.conf`** (см. комментарии в **`docker-compose.yaml`**, чарт **`charts/lampac`**).
|
||||
|
||||
## Где лежат файлы
|
||||
|
||||
Пути **`passwd`**, **`init.conf`**, **`current.conf`**, **`users.json`** задаются относительно **текущей рабочей директории процесса** Lampac (не от каталога модуля).
|
||||
|
||||
- Если **`current.conf`** на диске отсутствует или повреждён как JSON, при чтении подставляется снимок из **`CoreInit.CurrentConf`** (если доступен хостом).
|
||||
- HTML читаются из **`ModInit.modpath`** (каталог загруженного модуля): **`auth.html`**, **`index.html`**.
|
||||
|
||||
В Docker проверьте, что том с конфигом смонтирован туда же, откуда процесс видит те же имена файлов, что вы правите через UI.
|
||||
|
||||
### Запись на диск
|
||||
|
||||
Сохранение **`init.conf`** и **`users.json`** делается атомарно: сначала запись во временный файл, затем **`Move`** с перезаписью. Если на bind mount **`Move`** даёт ошибку занятости цели (**EBUSY**), выполняется прямая запись в целевой файл — это осознанный fallback для некоторых сценариев Docker.
|
||||
|
||||
Ответ успешной записи: **`{"ok":true}`**. Ошибки: **`400`/`500`** с телом **`{"error":"...", "detail":"..."}`** (поле **`detail`** опционально).
|
||||
|
||||
## Различие «init» и «current» в UI
|
||||
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| **`init.conf`** | То, что можно **сохранять** через API: целиком (**`POST /adminpanel/api/init`**) или по корневым ключам (**`POST .../init/section/{key}`**). |
|
||||
| **`current.conf`** | **Только чтение** в админке (**`GET /adminpanel/api/current`**): актуальный снимок/мердж конфигурации для просмотра и группировки секций в интерфейсе. Прямой POST для **`current`** в контроллере не предусмотрен. |
|
||||
|
||||
Если нужно изменить один корневой ключ в **`init.conf`**, тело **`POST /adminpanel/api/init/section/{key}`** — валидный JSON **значения** секции (объект, массив, примитив — как в корне конфига). Ключ секции не должен содержать **`/`** или **`\`**.
|
||||
|
||||
## HTTP API
|
||||
|
||||
Подробности тел запросов и валидации — в **`AdminPanelController.cs`**.
|
||||
|
||||
| Метод и маршрут | Описание |
|
||||
|-----------------|----------|
|
||||
| **`GET /adminpanel/auth`** | Страница входа (**`AllowAnonymous`**). |
|
||||
| **`GET /adminpanel`** | Главная панель (**`index.html`**). |
|
||||
| **`GET /adminpanel/api/groups`** | Группы секций по ключам, **присутствующим** в **`current.conf`** (или в памяти, если файл не прочитан), плюс блок **«Прочее»** для ключей без каталога. |
|
||||
| **`GET /adminpanel/api/groups/catalog`** | Полный **каталог** групп из **`ConfigSectionGroups.Catalog`**; если в актуальном конфиге есть ключи вне каталога — добавляется **«Прочее»** для них (подписи ориентированы на ключи из current). |
|
||||
| **`GET /adminpanel/api/init`** | Содержимое **`init.conf`** (пустой объект, если файла нет); JSON приводится к отформатированному виду когда возможно. |
|
||||
| **`POST /adminpanel/api/init`** | Тело — один JSON **объект** (корень **`init.conf`**). Целиком перезаписывает файл. |
|
||||
| **`POST /adminpanel/api/init/section/{key}`** | Обновить один корневой ключ в **`init.conf`**. |
|
||||
| **`GET /adminpanel/api/current`** | **`current.conf`** или fallback из **`CoreInit.CurrentConf`**. |
|
||||
| **`GET /adminpanel/api/users-json`** | Массив пользователей; если файла нет — **`[]`**. |
|
||||
| **`POST /adminpanel/api/users-json`** | Тело — JSON **массив** объектов пользователей (схема **accsdb**); перезаписывает **`users.json`**. |
|
||||
|
||||
## Группы в редакторе
|
||||
|
||||
Класс **`ConfigSectionGroups`** задаёт человекочитаемые группы и подсказки для бокового каталога: какие корневые ключи конфига к какой группе относятся (рантайм, listen, модули, источники и т.д.).
|
||||
|
||||
При добавлении нового модуля с секцией в корне JSON имеет смысл дописать её ключ в **`Catalog`** нужной группы или в блок **«modules»**, чтобы ключ не попадал только в **«Прочее»**.
|
||||
|
||||
Публичный **`GroupDto`** (см. низ **`ConfigSectionGroups.cs`**): **`id`**, **`title`**, **`hint`**, массив **`keys`**.
|
||||
|
||||
## Конфигурация модуля
|
||||
|
||||
Отдельной секции настроек в **`ModInit`** нет — только **`manifest.json`** (**`enable`**, **`dynamic`**, **`tree`**).
|
||||
|
||||
Статические ассеты панели лежат рядом с кодом модуля (**`index.html`**, **`auth.html`**); при изменении их в репозитории нужна перезагрузка/пересборка в соответствии с тем, как хост подхватывает динамические модули.
|
||||
|
||||
## Файлы модуля
|
||||
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| **`AdminPanelController.cs`** | Маршруты UI и API, работа с файлами конфигурации. |
|
||||
| **`ConfigSectionGroups.cs`** | Каталог групп и билдер **`GroupDto`**. |
|
||||
| **`ModInit.cs`** | **`IModuleLoaded`**: сохраняет **`modpath`** для раздачи HTML. |
|
||||
| **`manifest.json`** | Включение модуля и список подгружаемых **`.cs`**. |
|
||||
| **`auth.html`**, **`index.html`** | Интерфейс входа и панели. |
|
||||
@@ -1,401 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" data-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0c0e12" id="metaTheme">
|
||||
<title>Вход — Lampac Admin</title>
|
||||
<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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('lampac-theme');
|
||||
var dark = t === 'light' ? false : t === 'dark' ? true : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
} catch (e) { /* ignore */ }
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
--text-xs: 0.6875rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.875rem;
|
||||
--text-lg: 1rem;
|
||||
--sp-1: 0.25rem;
|
||||
--sp-2: 0.5rem;
|
||||
--sp-3: 0.75rem;
|
||||
--sp-4: 1rem;
|
||||
--sp-5: 1.25rem;
|
||||
--sp-6: 1.5rem;
|
||||
--sp-8: 2rem;
|
||||
--r-sm: 6px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
--r-xl: 16px;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--dur: 180ms;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #0c0e12;
|
||||
--surface: #13161d;
|
||||
--surface-2: #1a1e28;
|
||||
--surface-3: #222733;
|
||||
--border: #2a3040;
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--text: #e2e6ef;
|
||||
--text-muted: #7c849a;
|
||||
--text-faint: #4d5568;
|
||||
--accent: #3ec9d1;
|
||||
--accent-soft: #2ea8b0;
|
||||
--accent-dim: rgba(62, 201, 209, 0.12);
|
||||
--accent-hover: rgba(62, 201, 209, 0.22);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f4f5f8;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f0f1f5;
|
||||
--surface-3: #e8eaf0;
|
||||
--border: #d4d8e3;
|
||||
--border-subtle: rgba(0, 0, 0, 0.06);
|
||||
--text: #1a1e28;
|
||||
--text-muted: #6b7280;
|
||||
--text-faint: #9ca3af;
|
||||
--accent: #0d9499;
|
||||
--accent-soft: #0b7d82;
|
||||
--accent-dim: rgba(13, 148, 153, 0.1);
|
||||
--accent-hover: rgba(13, 148, 153, 0.18);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.auth-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-6);
|
||||
min-height: 52px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-lg);
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-logo svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-logo span {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.topbar-spacer {
|
||||
flex: 1;
|
||||
min-width: var(--sp-2);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--dur) var(--ease);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.btn-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.auth-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: var(--sp-6);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: authIn 280ms var(--ease);
|
||||
}
|
||||
|
||||
@keyframes authIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.auth-card .lead {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--sp-5);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.auth-field label {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.auth-field input[type="password"] {
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
[data-theme="light"] .auth-field input[type="password"] {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.auth-field input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.auth-field input::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--accent);
|
||||
cursor: pointer;
|
||||
transition: all var(--dur) var(--ease);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-soft);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.topbar-logo span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
var m = document.getElementById('metaTheme');
|
||||
if (m) m.setAttribute('content', dark ? '#0c0e12' : '#f4f5f8');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="auth-app">
|
||||
<header class="topbar">
|
||||
<div class="topbar-logo">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
|
||||
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
|
||||
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
|
||||
</svg>
|
||||
Lampac <span>Admin</span>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button type="button" class="btn-icon" id="themeBtn" title="Сменить тему (Alt+T)" aria-label="Сменить тему">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="auth-main">
|
||||
<div class="auth-card">
|
||||
<h1>Вход</h1>
|
||||
<p class="lead">Введите пароль доступа к панели. Сессия сохраняется в cookie на 180 дней.</p>
|
||||
<form class="auth-form" id="loginForm">
|
||||
<div class="auth-field">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var LAMPAC_THEME_STORAGE_KEY = 'lampac-theme';
|
||||
|
||||
function $(sel) { return document.querySelector(sel); }
|
||||
|
||||
function setTheme(dark) {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
var meta = $('#metaTheme');
|
||||
if (meta) meta.setAttribute('content', dark ? '#0c0e12' : '#f4f5f8');
|
||||
try { localStorage.setItem(LAMPAC_THEME_STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) { }
|
||||
syncThemeIcon();
|
||||
}
|
||||
|
||||
function syncThemeIcon() {
|
||||
var d = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
var icon = $('#themeIcon');
|
||||
if (!icon) return;
|
||||
if (d) {
|
||||
icon.innerHTML = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
|
||||
} else {
|
||||
icon.innerHTML = '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>';
|
||||
}
|
||||
}
|
||||
|
||||
syncThemeIcon();
|
||||
|
||||
$('#themeBtn').addEventListener('click', function () {
|
||||
var d = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
setTheme(!d);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.altKey && (e.key === 't' || e.key === 'T')) {
|
||||
e.preventDefault();
|
||||
var d = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
setTheme(!d);
|
||||
}
|
||||
});
|
||||
|
||||
$('#loginForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var password = $('#password').value;
|
||||
var maxAge = 60 * 60 * 24 * 180;
|
||||
document.cookie = 'accspasswd=' + encodeURIComponent(password) +
|
||||
'; max-age=' + maxAge +
|
||||
'; path=/; SameSite=Lax' +
|
||||
(window.isSecureContext ? '; Secure' : '');
|
||||
window.location.href = '/adminpanel';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,519 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Shared;
|
||||
using Shared.Attributes;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TelegramAuth.Models;
|
||||
using TelegramAuth.Services;
|
||||
|
||||
namespace TelegramAuth.Controllers
|
||||
{
|
||||
[Authorization("access denied")]
|
||||
public class TelegramAuthController : BaseController
|
||||
{
|
||||
public const string MutationsSecretHeaderName = "X-TelegramAuth-Mutations-Secret";
|
||||
|
||||
static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
static TelegramAuthStore store => ModInit.Store;
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/status")]
|
||||
public ActionResult Status([FromQuery] string uid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uid))
|
||||
return JsonError(400, "uid is required");
|
||||
|
||||
var status = store.GetStatus(uid);
|
||||
return JsonOk(status);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/me")]
|
||||
public ActionResult Me([FromQuery] string uid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uid))
|
||||
return JsonError(400, "uid is required");
|
||||
|
||||
var user = store.FindByUid(uid);
|
||||
if (user == null)
|
||||
return JsonError(404, "user not found for uid");
|
||||
|
||||
return JsonOk(user);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/bind/start")]
|
||||
public ActionResult BindStart([FromBody] BindStartRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Uid))
|
||||
return JsonError(400, "uid is required");
|
||||
|
||||
var response = new
|
||||
{
|
||||
ok = true,
|
||||
pending = true,
|
||||
uid = request.Uid,
|
||||
message = "Привяжи UID в Telegram-боте."
|
||||
};
|
||||
|
||||
return JsonOk(response);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/bind/complete")]
|
||||
public ActionResult BindComplete([FromBody] BindCompleteRequest? request)
|
||||
{
|
||||
if (HasConfiguredMutationsSecret() && !HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Uid) || string.IsNullOrWhiteSpace(request.TelegramId))
|
||||
return JsonError(400, "uid and telegramId are required");
|
||||
|
||||
try
|
||||
{
|
||||
var bindOutcome = store.BindDevice(request.TelegramId, request.Uid, request.Username, request.DeviceName, "manual-complete");
|
||||
return JsonOk(new
|
||||
{
|
||||
ok = true,
|
||||
message = bindOutcome.PendingAdminApproval
|
||||
? "Устройство привязано; доступ включит администратор"
|
||||
: "Устройство привязано",
|
||||
uid = request.Uid,
|
||||
telegramId = request.TelegramId,
|
||||
newUserProvisioned = bindOutcome.NewUserProvisioned,
|
||||
pendingAdminApproval = bindOutcome.PendingAdminApproval
|
||||
});
|
||||
}
|
||||
catch (TelegramAuthBindException ex) when (ex.FailureKind == TelegramAuthBindFailureKind.UserNotFound)
|
||||
{
|
||||
return JsonError(404, "user not found", "Включите auto_provision_users в TelegramAuth или добавьте пользователя в users.json.");
|
||||
}
|
||||
catch (TelegramAuthBindException ex) when (ex.FailureKind == TelegramAuthBindFailureKind.UserDisabled)
|
||||
{
|
||||
return JsonError(403, "user disabled", "Аккаунт отключён администратором.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonError(500, "bind failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/user/by-telegram")]
|
||||
public ActionResult UserByTelegram([FromQuery] string telegramId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var user = store.FindByTelegramId(telegramId);
|
||||
if (user == null)
|
||||
{
|
||||
return JsonOk(new
|
||||
{
|
||||
found = false,
|
||||
telegramId
|
||||
});
|
||||
}
|
||||
|
||||
var registrationPending = TelegramAuthStore.IsRegistrationPending(user);
|
||||
return JsonOk(new
|
||||
{
|
||||
found = true,
|
||||
telegramId = user.TelegramId,
|
||||
username = user.TgUsername,
|
||||
role = user.Role,
|
||||
lang = user.Lang,
|
||||
approvedBy = user.ApprovedBy,
|
||||
createdAt = user.CreatedAt,
|
||||
expiresAt = user.ExpiresAt,
|
||||
disabled = user.Disabled,
|
||||
registrationPending,
|
||||
active = store.IsActive(user),
|
||||
deviceCount = user.Devices.Count(d => d.Active),
|
||||
maxDevices = store.GetMaxDevices(user)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/users")]
|
||||
public ActionResult AdminListUsers()
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
var users = store.GetUsers()
|
||||
.OrderBy(u => u.TelegramId, StringComparer.Ordinal)
|
||||
.Select(u => new
|
||||
{
|
||||
telegramId = u.TelegramId,
|
||||
username = u.TgUsername,
|
||||
role = u.Role,
|
||||
disabled = u.Disabled,
|
||||
registrationPending = TelegramAuthStore.IsRegistrationPending(u),
|
||||
active = store.IsActive(u),
|
||||
expiresAt = u.ExpiresAt,
|
||||
deviceCount = u.Devices.Count(d => d.Active),
|
||||
accs = u.Accs == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
u.Accs.group,
|
||||
u.Accs.IsPasswd,
|
||||
u.Accs.ban,
|
||||
u.Accs.ban_msg,
|
||||
u.Accs.comment,
|
||||
u.Accs.ids,
|
||||
u.Accs.@params
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return JsonOk(new { ok = true, users });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/user")]
|
||||
public ActionResult AdminGetUser([FromQuery] string telegramId)
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var user = store.FindByTelegramId(telegramId.Trim());
|
||||
if (user == null)
|
||||
return JsonError(404, "user not found");
|
||||
|
||||
return JsonOk(new
|
||||
{
|
||||
ok = true,
|
||||
telegramId = user.TelegramId,
|
||||
username = user.TgUsername,
|
||||
role = user.Role,
|
||||
lang = user.Lang,
|
||||
approvedBy = user.ApprovedBy,
|
||||
createdAt = user.CreatedAt,
|
||||
expiresAt = user.ExpiresAt,
|
||||
disabled = user.Disabled,
|
||||
registrationPending = TelegramAuthStore.IsRegistrationPending(user),
|
||||
active = store.IsActive(user),
|
||||
deviceCount = user.Devices.Count(d => d.Active),
|
||||
maxDevices = store.GetMaxDevices(user),
|
||||
accs = user.Accs,
|
||||
devices = user.Devices.Select(d => new
|
||||
{
|
||||
uid = d.Uid,
|
||||
name = d.Name,
|
||||
active = d.Active,
|
||||
linkedAt = d.LinkedAt,
|
||||
lastSeenAt = d.LastSeenAt,
|
||||
source = d.Source
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/user/patch")]
|
||||
public async Task<ActionResult> AdminPatchUser()
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
string raw;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true))
|
||||
raw = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return JsonError(400, "body is required");
|
||||
|
||||
JObject body;
|
||||
try
|
||||
{
|
||||
body = JObject.Parse(raw);
|
||||
}
|
||||
catch (JsonReaderException)
|
||||
{
|
||||
return JsonError(400, "invalid json");
|
||||
}
|
||||
|
||||
var tid = body.Value<string>("telegramId");
|
||||
if (string.IsNullOrWhiteSpace(tid))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var outcome = store.TryAdminPatchUser(tid.Trim(), body, out var err);
|
||||
if (outcome == TelegramAuthStore.AdminPatchUserOutcome.NotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.AdminPatchUserOutcome.InvalidPayload)
|
||||
return JsonError(400, "invalid payload", err);
|
||||
|
||||
return JsonOk(new { ok = true, telegramId = tid.Trim() });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/user/disabled")]
|
||||
public ActionResult AdminSetUserDisabled([FromBody] AdminSetUserDisabledRequest? request)
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.TelegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var outcome = store.TrySetUserDisabled(request.TelegramId.Trim(), request.Disabled);
|
||||
if (outcome == TelegramAuthStore.SetUserDisabledOutcome.NotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.SetUserDisabledOutcome.CannotDisableAdmin)
|
||||
return JsonError(403, "cannot disable admin", "Нельзя отключить учётную запись с ролью admin.");
|
||||
|
||||
return JsonOk(new
|
||||
{
|
||||
ok = true,
|
||||
telegramId = request.TelegramId.Trim(),
|
||||
disabled = request.Disabled
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/user/pending")]
|
||||
public ActionResult AdminResolveRegistrationPending([FromBody] AdminPendingDecisionRequest? request)
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.TelegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var tid = request.TelegramId.Trim();
|
||||
if (request.Approve)
|
||||
{
|
||||
var outcome = store.TryApproveRegistrationPending(tid);
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotPending)
|
||||
return JsonError(400, "not pending", "Пользователь не в статусе ожидания подтверждения.");
|
||||
|
||||
return JsonOk(new { ok = true, telegramId = tid, approved = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
var outcome = store.TryRejectRegistrationPending(tid);
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotPending)
|
||||
return JsonError(400, "not pending", "Пользователь не в статусе ожидания подтверждения.");
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.CannotRejectAdmin)
|
||||
return JsonError(403, "cannot reject admin");
|
||||
|
||||
return JsonOk(new { ok = true, telegramId = tid, rejected = true });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/devices")]
|
||||
public ActionResult Devices([FromQuery] string telegramId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var user = store.FindByTelegramId(telegramId);
|
||||
if (user == null)
|
||||
return JsonError(404, "user not found");
|
||||
|
||||
return JsonOk(new
|
||||
{
|
||||
telegramId = user.TelegramId,
|
||||
username = user.TgUsername,
|
||||
devices = user.Devices
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/device/unbind")]
|
||||
public ActionResult Unbind([FromBody] DeviceUnbindRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Uid) || string.IsNullOrWhiteSpace(request.TelegramId))
|
||||
return JsonError(400, "telegramId and uid are required");
|
||||
|
||||
var outcome = store.TryUnbindDevice(request.TelegramId.Trim(), request.Uid.Trim());
|
||||
if (outcome == TelegramAuthStore.UnbindDeviceOutcome.UserNotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.UnbindDeviceOutcome.DeviceNotFound)
|
||||
return JsonError(404, "device not found", "UID не найден среди устройств этого пользователя.");
|
||||
|
||||
return JsonOk(new { ok = true, uid = request.Uid.Trim() });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/device/reactivate")]
|
||||
public ActionResult ReactivateDevice([FromBody] DeviceReactivateRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.TelegramId) || string.IsNullOrWhiteSpace(request.Uid))
|
||||
return JsonError(400, "telegramId and uid are required");
|
||||
|
||||
var outcome = store.TryReactivateDevice(request.TelegramId.Trim(), request.Uid.Trim());
|
||||
if (outcome == TelegramAuthStore.ReactivateDeviceOutcome.UserNotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.ReactivateDeviceOutcome.UserDisabled)
|
||||
return JsonError(403, "user disabled", "Аккаунт отключён администратором.");
|
||||
if (outcome == TelegramAuthStore.ReactivateDeviceOutcome.DeviceNotFound)
|
||||
return JsonError(404, "device not found", "UID не найден среди ваших устройств.");
|
||||
|
||||
return JsonOk(new { ok = true, uid = request.Uid.Trim() });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/device/name")]
|
||||
public ActionResult SetDeviceName([FromBody] DeviceSetNameRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Uid))
|
||||
return JsonError(400, "uid is required");
|
||||
|
||||
var outcome = store.TrySetActiveDeviceDisplayName(request.Uid, request.Name);
|
||||
if (outcome == TelegramAuthStore.SetDeviceDisplayNameOutcome.InvalidUid)
|
||||
return JsonError(400, "uid is required");
|
||||
if (outcome == TelegramAuthStore.SetDeviceDisplayNameOutcome.NotFoundOrInactive)
|
||||
return JsonError(404, "device not found or access inactive", "UID не привязан или срок доступа истёк.");
|
||||
|
||||
return JsonOk(new
|
||||
{
|
||||
ok = true,
|
||||
uid = request.Uid.Trim(),
|
||||
name = string.IsNullOrWhiteSpace(request.Name) ? (string?)null : request.Name.Trim()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/devices/cleanup")]
|
||||
public ActionResult CleanupDevices()
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (!ModInit.conf.enable_cleanup)
|
||||
return JsonError(404, "cleanup disabled");
|
||||
|
||||
try
|
||||
{
|
||||
var removed = store.CleanupInactiveDevices();
|
||||
return JsonOk(new { ok = true, removed });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonError(500, "cleanup failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/import")]
|
||||
public ActionResult ImportLegacy()
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (!ModInit.conf.enable_import)
|
||||
return JsonError(404, "import disabled");
|
||||
|
||||
var legacyPath = ModInit.conf.legacy_import_path?.Trim();
|
||||
if (string.IsNullOrEmpty(legacyPath))
|
||||
return JsonError(400, "legacy_import_path is not configured");
|
||||
|
||||
try
|
||||
{
|
||||
var result = store.ImportFromLegacy(legacyPath);
|
||||
return JsonOk(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonError(500, "import failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
bool HasConfiguredMutationsSecret() =>
|
||||
!string.IsNullOrEmpty(ModInit.conf.mutations_api_secret?.Trim());
|
||||
|
||||
bool HasMutationAccess()
|
||||
{
|
||||
if (Request.Cookies.TryGetValue("accspasswd", out var passwd)
|
||||
&& SecretEquals(passwd, CoreInit.rootPasswd))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var expected = ModInit.conf.mutations_api_secret?.Trim();
|
||||
if (string.IsNullOrEmpty(expected)
|
||||
|| !Request.Headers.TryGetValue(MutationsSecretHeaderName, out var supplied))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return SecretEquals(supplied.ToString().Trim(), expected);
|
||||
}
|
||||
|
||||
static bool SecretEquals(string? supplied, string? expected)
|
||||
{
|
||||
if (string.IsNullOrEmpty(supplied) || string.IsNullOrEmpty(expected))
|
||||
return false;
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(supplied),
|
||||
Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
|
||||
ContentResult MutationUnauthorized() => JsonError(401, "unauthorized");
|
||||
|
||||
ContentResult JsonOk(object data)
|
||||
{
|
||||
return new ContentResult
|
||||
{
|
||||
Content = JsonConvert.SerializeObject(data, JsonSettings),
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
StatusCode = 200
|
||||
};
|
||||
}
|
||||
|
||||
ContentResult JsonError(int status, string error, string? detail = null)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
error,
|
||||
detail
|
||||
};
|
||||
return new ContentResult
|
||||
{
|
||||
Content = JsonConvert.SerializeObject(payload, JsonSettings),
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
StatusCode = status
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Shared;
|
||||
using Shared.Models.AppConf;
|
||||
using Shared.Models.Events;
|
||||
using Shared.Models.Module;
|
||||
using Shared.Models.Module.Interfaces;
|
||||
using Shared.Services;
|
||||
using TelegramAuth.Models;
|
||||
using TelegramAuth.Services;
|
||||
|
||||
namespace TelegramAuth
|
||||
{
|
||||
public class ModInit : IModuleLoaded
|
||||
{
|
||||
public static TelegramAuthConf conf = new();
|
||||
|
||||
public static TelegramAuthStore Store { get; private set; } = null!;
|
||||
|
||||
public void Loaded(InitspaceModel initspace)
|
||||
{
|
||||
UpdateConfFromInit();
|
||||
EventListener.UpdateInitFile += UpdateConfFromInit;
|
||||
}
|
||||
|
||||
void UpdateConfFromInit()
|
||||
{
|
||||
conf = ModuleInvoke.Init("TelegramAuth", new TelegramAuthConf
|
||||
{
|
||||
enable = false,
|
||||
data_dir = null,
|
||||
legacy_import_path = "",
|
||||
enable_import = true,
|
||||
enable_cleanup = true,
|
||||
max_active_devices_per_user = 0,
|
||||
mutations_api_secret = "",
|
||||
owner_telegram_ids = null,
|
||||
auto_provision_users = false,
|
||||
auto_provision_role = "user",
|
||||
auto_provision_lang = "ru",
|
||||
auto_provision_expires_days = 0,
|
||||
auto_provision_activate_immediately = false,
|
||||
accsdb_sync_group_admin = 100,
|
||||
accsdb_sync_group_user = 0,
|
||||
limit_map = new List<WafLimitRootMap>
|
||||
{
|
||||
new("^/tg/auth", new WafLimitMap { limit = 5, second = 1 })
|
||||
}
|
||||
});
|
||||
|
||||
if (CoreInit.conf != null && conf.enable)
|
||||
CoreInit.conf.accsdb.enable = true;
|
||||
|
||||
Store = new TelegramAuthStore(conf);
|
||||
Store.EnsureStorage();
|
||||
Store.EnsureOwnerUsersAtStartup();
|
||||
|
||||
if (conf.enable)
|
||||
ApplyWafLimitMapFromConf();
|
||||
}
|
||||
|
||||
void ApplyWafLimitMapFromConf()
|
||||
{
|
||||
var waf = CoreInit.conf?.WAF?.limit_map;
|
||||
if (waf == null)
|
||||
return;
|
||||
|
||||
var ours = conf.limit_map ?? new List<WafLimitRootMap>();
|
||||
var patterns = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var m in ours)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(m?.pattern))
|
||||
patterns.Add(m.pattern);
|
||||
}
|
||||
|
||||
if (patterns.Count == 0)
|
||||
return;
|
||||
|
||||
waf.RemoveAll(x => x != null && !string.IsNullOrEmpty(x.pattern) && patterns.Contains(x.pattern));
|
||||
foreach (var m in ours)
|
||||
waf.Insert(0, m);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
EventListener.UpdateInitFile -= UpdateConfFromInit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Shared.Models.AppConf;
|
||||
using Shared.Models.Module;
|
||||
|
||||
namespace TelegramAuth.Models
|
||||
{
|
||||
public class TelegramAuthConf : ModuleBaseConf
|
||||
{
|
||||
public bool enable { get; set; }
|
||||
|
||||
public string? data_dir { get; set; }
|
||||
|
||||
public string legacy_import_path { get; set; } = "";
|
||||
|
||||
public bool enable_import { get; set; } = true;
|
||||
|
||||
public bool enable_cleanup { get; set; } = true;
|
||||
|
||||
public int max_active_devices_per_user { get; set; }
|
||||
|
||||
public string mutations_api_secret { get; set; } = "";
|
||||
|
||||
public long[]? owner_telegram_ids { get; set; }
|
||||
|
||||
public bool auto_provision_users { get; set; }
|
||||
|
||||
public string auto_provision_role { get; set; } = "user";
|
||||
|
||||
public string auto_provision_lang { get; set; } = "ru";
|
||||
|
||||
public int auto_provision_expires_days { get; set; }
|
||||
|
||||
public bool auto_provision_activate_immediately { get; set; }
|
||||
|
||||
public int accsdb_sync_group_admin { get; set; } = 100;
|
||||
|
||||
public int accsdb_sync_group_user { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace TelegramAuth.Models
|
||||
{
|
||||
public class LegacyDeviceRecord
|
||||
{
|
||||
public string uid { get; set; } = "";
|
||||
public string? label { get; set; }
|
||||
public string? bound_at { get; set; }
|
||||
public string? last_seen { get; set; }
|
||||
public string? last_ip { get; set; }
|
||||
public string? fingerprint { get; set; }
|
||||
}
|
||||
|
||||
public class LegacyTokenRecord
|
||||
{
|
||||
public string token { get; set; } = "";
|
||||
public string telegram_id { get; set; } = "";
|
||||
public string tg_username { get; set; } = "";
|
||||
public DateTime? created_at { get; set; }
|
||||
public DateTime? expires_at { get; set; }
|
||||
public string approved_by { get; set; } = "";
|
||||
public List<LegacyDeviceRecord> devices { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DeviceRecord
|
||||
{
|
||||
public string Uid { get; set; } = "";
|
||||
public string? Name { get; set; }
|
||||
public DateTime LinkedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
public bool Active { get; set; } = true;
|
||||
public string Source { get; set; } = "import";
|
||||
}
|
||||
|
||||
public class TelegramAccsProfile
|
||||
{
|
||||
[JsonProperty("group")]
|
||||
public int? group { get; set; }
|
||||
|
||||
[JsonProperty("IsPasswd")]
|
||||
public bool? IsPasswd { get; set; }
|
||||
|
||||
public bool? ban { get; set; }
|
||||
|
||||
public string? ban_msg { get; set; }
|
||||
|
||||
public string? comment { get; set; }
|
||||
|
||||
[JsonProperty("params")]
|
||||
public Dictionary<string, object>? @params { get; set; }
|
||||
|
||||
public List<string>? ids { get; set; }
|
||||
}
|
||||
|
||||
public class TelegramUserRecord
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
public string TgUsername { get; set; } = "";
|
||||
public string Role { get; set; } = "user";
|
||||
public string Lang { get; set; } = "ru";
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
public bool RegistrationPending { get; set; }
|
||||
|
||||
public List<DeviceRecord> Devices { get; set; } = new();
|
||||
|
||||
[JsonProperty("accs")]
|
||||
public TelegramAccsProfile? Accs { get; set; }
|
||||
}
|
||||
|
||||
public class AuthStatusResponse
|
||||
{
|
||||
public bool Authorized { get; set; }
|
||||
public bool Pending { get; set; }
|
||||
public bool RegistrationPending { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public string? TelegramId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public int DeviceCount { get; set; }
|
||||
}
|
||||
|
||||
public class BindStartRequest
|
||||
{
|
||||
public string Uid { get; set; } = "";
|
||||
}
|
||||
|
||||
public class DeviceSetNameRequest
|
||||
{
|
||||
[JsonProperty("uid", Required = Required.Always)]
|
||||
public string Uid { get; set; } = "";
|
||||
[JsonProperty("name")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
public class DeviceReactivateRequest
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
public string Uid { get; set; } = "";
|
||||
}
|
||||
|
||||
public class DeviceUnbindRequest
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
public string Uid { get; set; } = "";
|
||||
}
|
||||
|
||||
public class BindCompleteRequest
|
||||
{
|
||||
public string Uid { get; set; } = "";
|
||||
public string TelegramId { get; set; } = "";
|
||||
public string? Username { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public class ImportResult
|
||||
{
|
||||
public int ImportedUsers { get; set; }
|
||||
public int ImportedDevices { get; set; }
|
||||
public int ImportedAdmins { get; set; }
|
||||
public int ImportedLangs { get; set; }
|
||||
}
|
||||
|
||||
public class AdminSetUserDisabledRequest
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
public bool Disabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public class AdminPendingDecisionRequest
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
[JsonProperty("approve")]
|
||||
public bool Approve { get; set; } = true;
|
||||
}
|
||||
|
||||
public class BindDeviceOutcome
|
||||
{
|
||||
public bool NewUserProvisioned { get; set; }
|
||||
public bool PendingAdminApproval { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
# TelegramAuth
|
||||
|
||||
HTTP API и файловое хранилище для привязки **UID устройства Lampa** к учётке Telegram. Проект: **`Modules/Community/TelegramAuth/`**. Работает с [TelegramAuthBot](../TelegramAuthBot/README.md) и с клиентским плагином (см. [обзор Community](../README.md) — `deny.js` / `telegram_auth_gate.js`).
|
||||
|
||||
---
|
||||
|
||||
## Включение
|
||||
|
||||
- Убедитесь, что **`TelegramAuth`** (и при использовании бота — **`TelegramAuthBot`**) **нет** в **`BaseModule.SkipModules`** в `init.conf` (в шаблоне [`config/base.conf`](../../../config/base.conf) оба отключены по умолчанию).
|
||||
- [`manifest.json`](manifest.json): **`"enable": true`**.
|
||||
- `init.conf`: секция **`TelegramAuth`**. Пример: [`init.merge.example.json`](init.merge.example.json).
|
||||
|
||||
---
|
||||
|
||||
## Назначение
|
||||
|
||||
- Пользователи Telegram: `telegramId`, роль, срок доступа, устройства (UID).
|
||||
- Статус по UID для UI: привязан / ожидание / истёк срок.
|
||||
- Лимит **активных** устройств для роли `user` (`max_active_devices_per_user`; у `admin` — без лимита).
|
||||
- Опционально: импорт legacy, очистка устройств.
|
||||
|
||||
---
|
||||
|
||||
## Связь с accsdb
|
||||
|
||||
Если **`TelegramAuth.enable`** = **`true`**:
|
||||
|
||||
- В Core выставляется **`accsdb.enable`**.
|
||||
- Активные привязанные UID синхронизируются в **корневой** **`users.json`** (рядом с `init.conf`) в формате **`AccsUser`**. Хост обычно подхватывает файл в течение ~1 с.
|
||||
|
||||
Если **`enable`** = **`false`**: только хранилище и HTTP API, без accsdb и без подмешивания WAF из этой секции.
|
||||
|
||||
**Когда UID попадает в корневой `users.json`:** пользователь **не** в `RegistrationPending`, **не** `Disabled`, устройство **активно**. Иначе для затронутых UID выполняется **`RemoveUid`**. При нехватке слотов под новое устройство самое старое активное переводится в неактивное и его UID снимается с accsdb.
|
||||
|
||||
Поле **`accs`** в записи пользователя в **`data_dir`/users.json** задаёт группу, бан, `params` (в т.ч. `telegram_id`) и т.д.; срок в accsdb берётся из **`ExpiresAt`** или **`accsdb.shared_daytime`** / 365 дней. Подробная логика: [`Services/AccsdbUidSync.cs`](Services/AccsdbUidSync.cs).
|
||||
|
||||
**Регистрация:** при выключенном `auto_provision_users` запись с таким `telegramId` уже должна быть в **`data_dir`/users.json`. При включённом — может создаваться автоматически (см. таблицу конфига). Ожидание модерации: **`RegistrationPending`** + при создании часто **`Disabled`**. Подтверждение: `POST /tg/auth/admin/user/pending` или бот.
|
||||
|
||||
**`owner_telegram_ids`:** при старте модуля создаются/обновляются как **admin** в **`data_dir`/users.json**.
|
||||
|
||||
Ограничение **чатов** для админ-команд бота задаётся в **`TelegramAuthBot.admin_chat_ids`**, не здесь.
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация
|
||||
|
||||
| Поле | Описание |
|
||||
|------|----------|
|
||||
| `enable` | `true` — accsdb + синхронизация UID + применение **`limit_map`** к WAF. `false` (по умолчанию) — только API/хранилище. |
|
||||
| `data_dir` | Каталог данных (по умолчанию `database/tgauth`). |
|
||||
| `legacy_import_path` | База для `POST /tg/auth/import`. |
|
||||
| `enable_import` / `enable_cleanup` | Разрешить импорт / `POST /tg/auth/devices/cleanup`. |
|
||||
| `max_active_devices_per_user` | Лимит активных устройств для `user`; `0` → **5**. Для `admin` не применяется. |
|
||||
| `mutations_api_secret` | Заголовок **`X-TelegramAuth-Mutations-Secret`** (совпадает с ботом). Пусто → `bind/complete` без заголовка; мутации админа из бота без cookie — невозможны. |
|
||||
| `owner_telegram_ids` | Владельцы → admin при старте. |
|
||||
| `auto_provision_users` | Создавать пользователя при bind неизвестного `telegramId`. |
|
||||
| `auto_provision_role` | Роль новой записи (`admin` через auto-provision недоступен → `user`). |
|
||||
| `auto_provision_lang` | Язык по умолчанию. |
|
||||
| `auto_provision_expires_days` | Срок в днях; `0` — без срока. |
|
||||
| `auto_provision_activate_immediately` | `false` — pending до решения админа; `true` — сразу активен. |
|
||||
| `limit_map` | Правила WAF (тот же формат, что **`WAF.limit_map`** в Core). |
|
||||
| `accsdb_sync_group_admin` / `accsdb_sync_group_user` | Значение **`group`** в accsdb, если не задано в **`accs.group`** (по умолчанию 100 / 0). |
|
||||
|
||||
### WAF и `limit_map`
|
||||
|
||||
При **`TelegramAuth.enable`** записи из **`TelegramAuth.limit_map`** переносятся в **`CoreInit.conf.WAF.limit_map`**. Перед добавлением из глобального списка удаляются правила с тем же **`pattern`** (точное строковое совпадение, `Ordinal`). Core выбирает **первое** подходящее по пути правило; иначе — глобальный **`WAF.limit_req`**.
|
||||
|
||||
Запрос может **не попадать** под лимит, если для него выставлены **`IsLocalRequest`** / **`IsAnonymousRequest`** — см. порядок middleware в хосте.
|
||||
|
||||
Шаблон по умолчанию в примере: `^/tg/auth` с лимитом запросов с IP. Переопределение: [`init.merge.example.json`](init.merge.example.json).
|
||||
|
||||
---
|
||||
|
||||
## Хранилище (`data_dir`)
|
||||
|
||||
| Файл | Содержимое |
|
||||
|------|------------|
|
||||
| `users.json` | Пользователи Telegram, устройства, опционально **`accs`**. |
|
||||
| `admins.json` | Служебно (legacy-импорт). |
|
||||
| `user_langs.json` | `telegramId → язык`. |
|
||||
|
||||
---
|
||||
|
||||
## HTTP API (префикс `/tg/auth`)
|
||||
|
||||
Маршруты анонимны в смысле cookie-сессии Lampac; админские мутации требуют cookie **`accspasswd`** (root) **или** заголовок **`X-TelegramAuth-Mutations-Secret`** при **непустом** `mutations_api_secret`. Если секрет пуст — заголовок не принимается, для мутаций остаётся только cookie.
|
||||
|
||||
**`POST /tg/auth/bind/complete`:** секрет обязателен только если `mutations_api_secret` не пустой.
|
||||
|
||||
### Чтение / статус
|
||||
|
||||
- `GET /tg/auth/status?uid=` — авторизован ли UID, срок, роль, число устройств.
|
||||
- `GET /tg/auth/me?uid=` — полная запись владельца UID (404 если нет).
|
||||
|
||||
### Пользователь по Telegram
|
||||
|
||||
- `GET /tg/auth/user/by-telegram?telegramId=` — сводка для бота.
|
||||
- `GET /tg/auth/devices?telegramId=` — список устройств.
|
||||
|
||||
### Привязка и устройства
|
||||
|
||||
- `POST /tg/auth/bind/start` — `{ "uid" }`.
|
||||
- `POST /tg/auth/bind/complete` — `{ "uid", "telegramId", "username?", "deviceName?" }`.
|
||||
- `POST /tg/auth/device/name` — `{ "uid", "name?" }` (плагин Lampa после успешного статуса).
|
||||
- `POST /tg/auth/device/unbind` — `{ "telegramId", "uid" }` (без секрета мутаций).
|
||||
- `POST /tg/auth/device/reactivate` — `{ "telegramId", "uid" }` (403 если pending/disabled).
|
||||
|
||||
### Административные
|
||||
|
||||
- `GET /tg/auth/admin/users`
|
||||
- `GET /tg/auth/admin/user?telegramId=`
|
||||
- `POST /tg/auth/admin/user/patch` — патч профиля и **`accs`**, см. код/бот `/setuser`.
|
||||
- `POST /tg/auth/admin/user/disabled` — `{ "telegramId", "disabled" }` (admin нельзя отключить).
|
||||
- `POST /tg/auth/admin/user/pending` — `{ "telegramId", "approve" }` (отклонение удаляет пользователя из `data_dir` и снимает UID с accsdb).
|
||||
- `POST /tg/auth/import`
|
||||
- `POST /tg/auth/devices/cleanup`
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Не коммитьте **`mutations_api_secret`**. В проде лучше ненулевой секрет.
|
||||
- Расширяйте **`limit_map`** при необходимости.
|
||||
- GET с `uid` / `telegramId` раскрывают факт привязки — при жёсткой модели угроз ограничивайте сеть/прокси.
|
||||
|
||||
---
|
||||
|
||||
## Связь с ботом и клиентом
|
||||
|
||||
- Бот ходит на **`TelegramAuthBot.lampac_base_url`** + пути `tg/auth/...`. Секрет должен совпадать с **`TelegramAuth.mutations_api_secret`**.
|
||||
- Клиент Lampa: [Community README — плагины](../README.md).
|
||||
|
||||
---
|
||||
|
||||
## Legacy-импорт
|
||||
|
||||
Каталог **`legacy_import_path`**: минимум **`tokens.json`**; опционально `admin_ids.json`, `user_langs.json`. Форматы — `TelegramAuthStore.ImportFromLegacy`. Запись с `approved_by` = **`registration-pending`** → pending/disabled как при auto-provision без немедленной активации.
|
||||
@@ -1,168 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Shared.Models.Base;
|
||||
using TelegramAuth.Models;
|
||||
|
||||
namespace TelegramAuth.Services
|
||||
{
|
||||
internal static class AccsdbUidSync
|
||||
{
|
||||
const string UsersFileName = "users.json";
|
||||
static readonly object FileLock = new object();
|
||||
|
||||
static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.Indented,
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
public static void UpsertTelegramDevice(string deviceUid, TelegramUserRecord tgUser, DateTime expiresLocal, int groupAdmin, int groupUser)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceUid) || tgUser == null)
|
||||
return;
|
||||
|
||||
var row = BuildAccsUserForDevice(deviceUid.Trim(), tgUser, expiresLocal, groupAdmin, groupUser);
|
||||
lock (FileLock)
|
||||
{
|
||||
EnsureFileExistsUnlocked();
|
||||
|
||||
var list = ReadListUnlocked();
|
||||
var key = row.id;
|
||||
var found = list.FirstOrDefault(u =>
|
||||
(u.id != null && string.Equals(u.id, key, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(u.ids != null && u.ids.Any(id => string.Equals(id, key, StringComparison.OrdinalIgnoreCase))));
|
||||
|
||||
if (found != null)
|
||||
{
|
||||
found.id = row.id;
|
||||
found.ids = row.ids ?? new List<string>();
|
||||
found.IsPasswd = row.IsPasswd;
|
||||
found.expires = row.expires;
|
||||
found.group = row.group;
|
||||
found.ban = row.ban;
|
||||
found.ban_msg = row.ban_msg;
|
||||
found.comment = row.comment;
|
||||
found.@params = row.@params;
|
||||
}
|
||||
else
|
||||
list.Add(row);
|
||||
|
||||
WriteListUnlocked(list);
|
||||
}
|
||||
}
|
||||
|
||||
static AccsUser BuildAccsUserForDevice(string deviceUid, TelegramUserRecord tg, DateTime expiresLocal, int groupAdmin, int groupUser)
|
||||
{
|
||||
var accs = tg.Accs;
|
||||
var group = accs?.group ?? (string.Equals(tg.Role, "admin", StringComparison.OrdinalIgnoreCase) ? groupAdmin : groupUser);
|
||||
var isPasswd = accs?.IsPasswd ?? false;
|
||||
var banned = accs?.ban == true;
|
||||
|
||||
var ids = accs?.ids != null && accs.ids.Count > 0
|
||||
? accs.ids.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
|
||||
: new List<string>();
|
||||
|
||||
var paramDict = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
if (accs?.@params != null)
|
||||
{
|
||||
foreach (var kv in accs.@params)
|
||||
paramDict[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
paramDict["telegram_id"] = tg.TelegramId;
|
||||
if (!string.IsNullOrWhiteSpace(tg.TgUsername))
|
||||
paramDict["telegram_username"] = tg.TgUsername.Trim();
|
||||
|
||||
string comment;
|
||||
if (!string.IsNullOrWhiteSpace(accs?.comment))
|
||||
comment = accs.comment.Trim();
|
||||
else if (!string.IsNullOrWhiteSpace(tg.TgUsername))
|
||||
comment = $"telegram:{tg.TelegramId} @{tg.TgUsername.Trim()}";
|
||||
else
|
||||
comment = $"telegram:{tg.TelegramId}";
|
||||
|
||||
return new AccsUser
|
||||
{
|
||||
id = deviceUid,
|
||||
ids = ids,
|
||||
expires = expiresLocal,
|
||||
group = group,
|
||||
ban = banned,
|
||||
ban_msg = string.IsNullOrWhiteSpace(accs?.ban_msg) ? null : accs.ban_msg.Trim(),
|
||||
comment = comment,
|
||||
IsPasswd = isPasswd,
|
||||
@params = paramDict
|
||||
};
|
||||
}
|
||||
|
||||
public static void RemoveUid(string deviceUid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceUid))
|
||||
return;
|
||||
|
||||
var key = deviceUid.Trim();
|
||||
lock (FileLock)
|
||||
{
|
||||
if (!File.Exists(Path.GetFullPath(UsersFileName)))
|
||||
return;
|
||||
|
||||
var list = ReadListUnlocked();
|
||||
foreach (var u in list)
|
||||
{
|
||||
if (u.ids != null && u.ids.Count > 0)
|
||||
u.ids = u.ids.Where(id => !string.Equals(id, key, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
list = list
|
||||
.Where(u => u.id == null || !string.Equals(u.id, key, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
WriteListUnlocked(list);
|
||||
}
|
||||
}
|
||||
|
||||
static void EnsureFileExistsUnlocked()
|
||||
{
|
||||
var full = Path.GetFullPath(UsersFileName);
|
||||
var dir = Path.GetDirectoryName(full);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
if (!File.Exists(full))
|
||||
File.WriteAllText(full, "[]" + Environment.NewLine);
|
||||
}
|
||||
|
||||
static List<AccsUser> ReadListUnlocked()
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(UsersFileName);
|
||||
if (!File.Exists(full))
|
||||
return new List<AccsUser>();
|
||||
|
||||
var txt = File.ReadAllText(full);
|
||||
if (string.IsNullOrWhiteSpace(txt))
|
||||
return new List<AccsUser>();
|
||||
|
||||
return JsonConvert.DeserializeObject<List<AccsUser>>(txt) ?? new List<AccsUser>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<AccsUser>();
|
||||
}
|
||||
}
|
||||
|
||||
static void WriteListUnlocked(List<AccsUser> list)
|
||||
{
|
||||
var full = Path.GetFullPath(UsersFileName);
|
||||
var dir = Path.GetDirectoryName(full);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
File.WriteAllText(full, JsonConvert.SerializeObject(list, JsonSettings));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace TelegramAuth.Services
|
||||
{
|
||||
public enum TelegramAuthBindFailureKind
|
||||
{
|
||||
UserNotFound,
|
||||
UserDisabled
|
||||
}
|
||||
|
||||
public sealed class TelegramAuthBindException : Exception
|
||||
{
|
||||
public TelegramAuthBindFailureKind FailureKind { get; }
|
||||
|
||||
public TelegramAuthBindException(TelegramAuthBindFailureKind failureKind, string message)
|
||||
: base(message)
|
||||
{
|
||||
FailureKind = failureKind;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using TelegramAuth.Models;
|
||||
|
||||
namespace TelegramAuth.Services
|
||||
{
|
||||
public partial class TelegramAuthStore
|
||||
{
|
||||
public enum SetUserDisabledOutcome
|
||||
{
|
||||
Ok,
|
||||
NotFound,
|
||||
CannotDisableAdmin
|
||||
}
|
||||
|
||||
public SetUserDisabledOutcome TrySetUserDisabled(string telegramId, bool disabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
return SetUserDisabledOutcome.NotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, telegramId, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return SetUserDisabledOutcome.NotFound;
|
||||
|
||||
if (disabled && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
return SetUserDisabledOutcome.CannotDisableAdmin;
|
||||
|
||||
user.Disabled = disabled;
|
||||
if (disabled)
|
||||
{
|
||||
user.RegistrationPending = false;
|
||||
if (ShouldSyncAccsdb() && user.Devices != null)
|
||||
{
|
||||
foreach (var d in user.Devices)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(d.Uid))
|
||||
AccsdbUidSync.RemoveUid(d.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
if (user.Devices != null)
|
||||
{
|
||||
foreach (var d in user.Devices)
|
||||
d.Active = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
user.RegistrationPending = false;
|
||||
|
||||
SaveUsers(users);
|
||||
|
||||
if (!disabled && ShouldSyncAccsdb() && IsActive(user))
|
||||
{
|
||||
foreach (var d in user.Devices ?? new List<DeviceRecord>())
|
||||
{
|
||||
if (d.Active && !string.IsNullOrWhiteSpace(d.Uid))
|
||||
SyncDeviceToAccsdb(user, d.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
return SetUserDisabledOutcome.Ok;
|
||||
}
|
||||
|
||||
public enum PendingDecisionOutcome
|
||||
{
|
||||
Ok,
|
||||
NotFound,
|
||||
NotPending,
|
||||
CannotRejectAdmin
|
||||
}
|
||||
|
||||
public PendingDecisionOutcome TryApproveRegistrationPending(string telegramId)
|
||||
{
|
||||
var tid = telegramId?.Trim();
|
||||
if (string.IsNullOrEmpty(tid))
|
||||
return PendingDecisionOutcome.NotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return PendingDecisionOutcome.NotFound;
|
||||
if (!IsRegistrationPending(user))
|
||||
return PendingDecisionOutcome.NotPending;
|
||||
|
||||
user.RegistrationPending = false;
|
||||
user.Disabled = false;
|
||||
user.ApprovedBy = "admin-approved";
|
||||
SaveUsers(users);
|
||||
if (ShouldSyncAccsdb() && IsActive(user))
|
||||
{
|
||||
foreach (var d in user.Devices ?? new List<DeviceRecord>())
|
||||
{
|
||||
if (d.Active && !string.IsNullOrWhiteSpace(d.Uid))
|
||||
SyncDeviceToAccsdb(user, d.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
return PendingDecisionOutcome.Ok;
|
||||
}
|
||||
|
||||
public PendingDecisionOutcome TryRejectRegistrationPending(string telegramId)
|
||||
{
|
||||
var tid = telegramId?.Trim();
|
||||
if (string.IsNullOrEmpty(tid))
|
||||
return PendingDecisionOutcome.NotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return PendingDecisionOutcome.NotFound;
|
||||
if (!IsRegistrationPending(user))
|
||||
return PendingDecisionOutcome.NotPending;
|
||||
if (string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
return PendingDecisionOutcome.CannotRejectAdmin;
|
||||
|
||||
if (ShouldSyncAccsdb())
|
||||
{
|
||||
foreach (var d in user.Devices ?? new List<DeviceRecord>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(d.Uid))
|
||||
AccsdbUidSync.RemoveUid(d.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
users.Remove(user);
|
||||
SaveUsers(users);
|
||||
return PendingDecisionOutcome.Ok;
|
||||
}
|
||||
|
||||
internal const int DeviceDisplayNameMaxLen = 500;
|
||||
|
||||
public enum SetDeviceDisplayNameOutcome
|
||||
{
|
||||
Ok,
|
||||
InvalidUid,
|
||||
NotFoundOrInactive
|
||||
}
|
||||
|
||||
public enum ReactivateDeviceOutcome
|
||||
{
|
||||
Ok,
|
||||
UserNotFound,
|
||||
UserDisabled,
|
||||
DeviceNotFound
|
||||
}
|
||||
|
||||
public enum UnbindDeviceOutcome
|
||||
{
|
||||
Ok,
|
||||
UserNotFound,
|
||||
DeviceNotFound
|
||||
}
|
||||
|
||||
public UnbindDeviceOutcome TryUnbindDevice(string telegramId, string uid)
|
||||
{
|
||||
var tid = telegramId?.Trim();
|
||||
var u = uid?.Trim();
|
||||
if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(u))
|
||||
return UnbindDeviceOutcome.DeviceNotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(x => string.Equals(x.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return UnbindDeviceOutcome.UserNotFound;
|
||||
|
||||
var device = user.Devices?.FirstOrDefault(d => string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase));
|
||||
if (device == null)
|
||||
return UnbindDeviceOutcome.DeviceNotFound;
|
||||
|
||||
device.Active = false;
|
||||
SaveUsers(users);
|
||||
if (ShouldSyncAccsdb())
|
||||
AccsdbUidSync.RemoveUid(u);
|
||||
return UnbindDeviceOutcome.Ok;
|
||||
}
|
||||
|
||||
public ReactivateDeviceOutcome TryReactivateDevice(string telegramId, string uid)
|
||||
{
|
||||
var tid = telegramId?.Trim();
|
||||
var u = uid?.Trim();
|
||||
if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(u))
|
||||
return ReactivateDeviceOutcome.DeviceNotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(x => string.Equals(x.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return ReactivateDeviceOutcome.UserNotFound;
|
||||
if (IsRegistrationPending(user) || user.Disabled)
|
||||
return ReactivateDeviceOutcome.UserDisabled;
|
||||
|
||||
var device = user.Devices?.FirstOrDefault(d => string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase));
|
||||
if (device == null)
|
||||
return ReactivateDeviceOutcome.DeviceNotFound;
|
||||
if (device.Active)
|
||||
return ReactivateDeviceOutcome.Ok;
|
||||
|
||||
CleanupInactiveDevices(users, 90);
|
||||
|
||||
BumpOldestActiveDeviceIfAtDeviceLimit(user);
|
||||
|
||||
device.Active = true;
|
||||
device.LastSeenAt = DateTime.UtcNow;
|
||||
SaveUsers(users);
|
||||
if (ShouldSyncAccsdb() && IsActive(user) && device.Active)
|
||||
SyncDeviceToAccsdb(user, u);
|
||||
return ReactivateDeviceOutcome.Ok;
|
||||
}
|
||||
|
||||
public SetDeviceDisplayNameOutcome TrySetActiveDeviceDisplayName(string uid, string? name)
|
||||
{
|
||||
var u = uid?.Trim();
|
||||
if (string.IsNullOrEmpty(u))
|
||||
return SetDeviceDisplayNameOutcome.InvalidUid;
|
||||
|
||||
string? resolved = null;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
resolved = name.Trim();
|
||||
if (resolved.Length > DeviceDisplayNameMaxLen)
|
||||
resolved = resolved.Substring(0, DeviceDisplayNameMaxLen);
|
||||
}
|
||||
|
||||
var users = GetUsers();
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (!IsActive(user))
|
||||
continue;
|
||||
|
||||
var dev = user.Devices?.FirstOrDefault(d => d.Active && string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase));
|
||||
if (dev == null)
|
||||
continue;
|
||||
|
||||
dev.Name = resolved;
|
||||
SaveUsers(users);
|
||||
return SetDeviceDisplayNameOutcome.Ok;
|
||||
}
|
||||
|
||||
return SetDeviceDisplayNameOutcome.NotFoundOrInactive;
|
||||
}
|
||||
|
||||
public int GetMaxDevices(TelegramUserRecord user)
|
||||
{
|
||||
if (user != null && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
return AdminUnlimitedDevices;
|
||||
|
||||
int max = _conf.max_active_devices_per_user;
|
||||
return max > 0 ? max : MaxActiveDevicesPerUser;
|
||||
}
|
||||
|
||||
public const string BindUserNotFoundMessage = "user not found";
|
||||
public const string BindUserDisabledMessage = "user disabled";
|
||||
|
||||
public BindDeviceOutcome BindDevice(string telegramId, string uid, string? telegramUsername = null, string? deviceDisplayName = null, string source = "manual")
|
||||
{
|
||||
var outcome = new BindDeviceOutcome();
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(u => u.TelegramId == telegramId);
|
||||
if (user == null)
|
||||
{
|
||||
if (!_conf.auto_provision_users)
|
||||
throw new TelegramAuthBindException(TelegramAuthBindFailureKind.UserNotFound, BindUserNotFoundMessage);
|
||||
|
||||
DateTime? expires = null;
|
||||
var days = _conf.auto_provision_expires_days;
|
||||
if (days > 0)
|
||||
expires = DateTime.UtcNow.AddDays(days);
|
||||
|
||||
var role = string.IsNullOrWhiteSpace(_conf.auto_provision_role)
|
||||
? "user"
|
||||
: _conf.auto_provision_role.Trim();
|
||||
if (string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
role = "user";
|
||||
|
||||
var pendingApproval = !_conf.auto_provision_activate_immediately;
|
||||
user = new TelegramUserRecord
|
||||
{
|
||||
TelegramId = telegramId,
|
||||
TgUsername = telegramUsername?.Trim() ?? "",
|
||||
Role = role,
|
||||
Lang = string.IsNullOrWhiteSpace(_conf.auto_provision_lang) ? "ru" : _conf.auto_provision_lang.Trim(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = expires,
|
||||
ApprovedBy = pendingApproval ? RegistrationPendingApprovedByMarker : "auto-provision",
|
||||
Disabled = pendingApproval,
|
||||
RegistrationPending = pendingApproval,
|
||||
Devices = new List<DeviceRecord>()
|
||||
};
|
||||
users.Add(user);
|
||||
outcome.NewUserProvisioned = true;
|
||||
outcome.PendingAdminApproval = pendingApproval;
|
||||
}
|
||||
else if (user.Disabled && !IsRegistrationPending(user))
|
||||
{
|
||||
throw new TelegramAuthBindException(TelegramAuthBindFailureKind.UserDisabled, BindUserDisabledMessage);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(telegramUsername))
|
||||
{
|
||||
var trimmed = telegramUsername.Trim();
|
||||
if (!string.Equals(user.TgUsername, trimmed, StringComparison.Ordinal))
|
||||
user.TgUsername = trimmed;
|
||||
}
|
||||
|
||||
string? resolvedDeviceName = null;
|
||||
if (!string.IsNullOrWhiteSpace(deviceDisplayName))
|
||||
{
|
||||
var t = deviceDisplayName.Trim();
|
||||
resolvedDeviceName = t.Length > DeviceDisplayNameMaxLen ? t.Substring(0, DeviceDisplayNameMaxLen) : t;
|
||||
}
|
||||
|
||||
CleanupInactiveDevices(users, 90);
|
||||
|
||||
var existing = user.Devices.FirstOrDefault(d => string.Equals(d.Uid, uid, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing == null)
|
||||
{
|
||||
BumpOldestActiveDeviceIfAtDeviceLimit(user);
|
||||
|
||||
user.Devices.Add(new DeviceRecord
|
||||
{
|
||||
Uid = uid,
|
||||
Name = resolvedDeviceName,
|
||||
LinkedAt = DateTime.UtcNow,
|
||||
LastSeenAt = DateTime.UtcNow,
|
||||
Active = true,
|
||||
Source = source
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!existing.Active)
|
||||
BumpOldestActiveDeviceIfAtDeviceLimit(user);
|
||||
|
||||
existing.Active = true;
|
||||
if (!string.IsNullOrWhiteSpace(resolvedDeviceName))
|
||||
existing.Name = resolvedDeviceName;
|
||||
existing.LastSeenAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
SaveUsers(users);
|
||||
SyncAccsdbForBind(user, uid);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
public AuthStatusResponse GetStatus(string uid)
|
||||
{
|
||||
var user = FindByUid(uid);
|
||||
if (user == null)
|
||||
{
|
||||
return new AuthStatusResponse
|
||||
{
|
||||
Authorized = false,
|
||||
Pending = true,
|
||||
Message = "Устройство не привязано. Нужна авторизация через Telegram."
|
||||
};
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (IsRegistrationPending(user))
|
||||
{
|
||||
return new AuthStatusResponse
|
||||
{
|
||||
Authorized = false,
|
||||
Pending = true,
|
||||
RegistrationPending = true,
|
||||
Message = "Аккаунт ожидает подтверждения администратора.",
|
||||
TelegramId = user.TelegramId,
|
||||
Username = user.TgUsername,
|
||||
Role = user.Role,
|
||||
ExpiresAt = user.ExpiresAt,
|
||||
DeviceCount = user.Devices.Count(d => d.Active)
|
||||
};
|
||||
}
|
||||
|
||||
if (user.Disabled)
|
||||
{
|
||||
return new AuthStatusResponse
|
||||
{
|
||||
Authorized = false,
|
||||
Pending = false,
|
||||
RegistrationPending = false,
|
||||
Message = "Доступ отключён администратором.",
|
||||
TelegramId = user.TelegramId,
|
||||
Username = user.TgUsername,
|
||||
Role = user.Role,
|
||||
ExpiresAt = user.ExpiresAt,
|
||||
DeviceCount = user.Devices.Count(d => d.Active)
|
||||
};
|
||||
}
|
||||
|
||||
var expired = user.ExpiresAt.HasValue && user.ExpiresAt.Value.ToUniversalTime() < now;
|
||||
return new AuthStatusResponse
|
||||
{
|
||||
Authorized = !expired,
|
||||
Pending = false,
|
||||
RegistrationPending = false,
|
||||
Message = expired ? "Срок доступа истёк." : "OK",
|
||||
TelegramId = user.TelegramId,
|
||||
Username = user.TgUsername,
|
||||
Role = user.Role,
|
||||
ExpiresAt = user.ExpiresAt,
|
||||
DeviceCount = user.Devices.Count(d => d.Active)
|
||||
};
|
||||
}
|
||||
|
||||
public int CleanupInactiveDevices(List<TelegramUserRecord>? users = null, int olderThanDays = 90)
|
||||
{
|
||||
users ??= GetUsers();
|
||||
var threshold = DateTime.UtcNow.AddDays(-olderThanDays);
|
||||
var removed = 0;
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (user.Devices == null)
|
||||
continue;
|
||||
|
||||
var before = user.Devices.Count;
|
||||
user.Devices = user.Devices
|
||||
.Where(d => d.Active || ((d.LastSeenAt ?? d.LinkedAt) >= threshold))
|
||||
.ToList();
|
||||
|
||||
var maxDevices = GetMaxDevices(user);
|
||||
if (maxDevices > 0)
|
||||
{
|
||||
var activeDevices = user.Devices.Where(d => d.Active).OrderByDescending(d => d.LastSeenAt ?? d.LinkedAt).ToList();
|
||||
if (activeDevices.Count > maxDevices)
|
||||
{
|
||||
foreach (var extra in activeDevices.Skip(maxDevices))
|
||||
{
|
||||
extra.Active = false;
|
||||
RemoveUidFromAccsdbIfSync(extra.Uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removed += before - user.Devices.Count;
|
||||
}
|
||||
|
||||
SaveUsers(users);
|
||||
return removed;
|
||||
}
|
||||
|
||||
public enum AdminPatchUserOutcome
|
||||
{
|
||||
Ok,
|
||||
NotFound,
|
||||
InvalidPayload
|
||||
}
|
||||
|
||||
public AdminPatchUserOutcome TryAdminPatchUser(string telegramId, JObject body, out string? errorDetail)
|
||||
{
|
||||
errorDetail = null;
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
{
|
||||
errorDetail = "telegramId is required";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
if (body == null)
|
||||
{
|
||||
errorDetail = "body is required";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
var users = GetUsers();
|
||||
var tid = telegramId.Trim();
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return AdminPatchUserOutcome.NotFound;
|
||||
|
||||
if (body.ContainsKey("expiresAt"))
|
||||
{
|
||||
var t = body["expiresAt"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
user.ExpiresAt = null;
|
||||
else if (t.Type == JTokenType.String && string.IsNullOrWhiteSpace(t.Value<string>()))
|
||||
user.ExpiresAt = null;
|
||||
else
|
||||
{
|
||||
var s = t.Type == JTokenType.String ? t.Value<string>() : t.ToString();
|
||||
if (!DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt))
|
||||
{
|
||||
errorDetail = "expiresAt: invalid date";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
user.ExpiresAt = dt;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.ContainsKey("lang"))
|
||||
{
|
||||
var s = body["lang"]?.Value<string>()?.Trim();
|
||||
if (!string.IsNullOrEmpty(s))
|
||||
user.Lang = s;
|
||||
}
|
||||
|
||||
if (body.ContainsKey("role"))
|
||||
{
|
||||
var r = body["role"]?.Value<string>()?.Trim();
|
||||
if (string.IsNullOrEmpty(r))
|
||||
{
|
||||
errorDetail = "role: empty";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
if (!string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(r, "user", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errorDetail = "role: use user or admin";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
user.Role = string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "user";
|
||||
}
|
||||
|
||||
if (body["accs"] is JObject accsJo)
|
||||
{
|
||||
user.Accs ??= new TelegramAccsProfile();
|
||||
MergeAccsPatch(user.Accs, accsJo, out var accsErr);
|
||||
if (accsErr != null)
|
||||
{
|
||||
errorDetail = accsErr;
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
}
|
||||
|
||||
if (body["accsRemove"] is JArray rem)
|
||||
{
|
||||
user.Accs ??= new TelegramAccsProfile();
|
||||
foreach (var x in rem)
|
||||
{
|
||||
var key = x.Value<string>()?.Trim();
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
ApplyAccsRemove(user.Accs, key);
|
||||
}
|
||||
}
|
||||
|
||||
if (body["paramsRemove"] is JArray pr)
|
||||
{
|
||||
user.Accs ??= new TelegramAccsProfile();
|
||||
user.Accs.@params ??= new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var x in pr)
|
||||
{
|
||||
var k = x.Value<string>();
|
||||
if (!string.IsNullOrEmpty(k))
|
||||
user.Accs.@params.Remove(k);
|
||||
}
|
||||
}
|
||||
|
||||
SaveUsers(users);
|
||||
ResyncUserDevicesToAccsdb(user);
|
||||
return AdminPatchUserOutcome.Ok;
|
||||
}
|
||||
|
||||
static void MergeAccsPatch(TelegramAccsProfile accs, JObject jo, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (jo.ContainsKey("group"))
|
||||
{
|
||||
var t = jo["group"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.group = null;
|
||||
else if (t.Type == JTokenType.Integer || t.Type == JTokenType.Float)
|
||||
accs.group = (int)t.Value<long>();
|
||||
else
|
||||
{
|
||||
error = "accs.group: expected integer or null";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("IsPasswd"))
|
||||
{
|
||||
var t = jo["IsPasswd"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.IsPasswd = null;
|
||||
else if (t.Type != JTokenType.Boolean)
|
||||
{
|
||||
error = "accs.IsPasswd: expected boolean or null";
|
||||
return;
|
||||
}
|
||||
else
|
||||
accs.IsPasswd = t.Value<bool>();
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("ban"))
|
||||
{
|
||||
var t = jo["ban"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.ban = null;
|
||||
else if (t.Type != JTokenType.Boolean)
|
||||
{
|
||||
error = "accs.ban: expected boolean or null";
|
||||
return;
|
||||
}
|
||||
else
|
||||
accs.ban = t.Value<bool>();
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("ban_msg"))
|
||||
{
|
||||
var t = jo["ban_msg"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.ban_msg = null;
|
||||
else
|
||||
{
|
||||
var s = t.Value<string>()?.Trim();
|
||||
accs.ban_msg = string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("comment"))
|
||||
{
|
||||
var t = jo["comment"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.comment = null;
|
||||
else
|
||||
{
|
||||
var s = t.Value<string>()?.Trim();
|
||||
accs.comment = string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("ids"))
|
||||
{
|
||||
var arr = jo["ids"] as JArray;
|
||||
if (arr == null || arr.Count == 0)
|
||||
accs.ids = null;
|
||||
else
|
||||
{
|
||||
accs.ids = arr
|
||||
.Select(x => x?.ToString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
if (jo["params"] is JObject pjo)
|
||||
{
|
||||
accs.@params ??= new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var prop in pjo.Properties())
|
||||
{
|
||||
if (prop.Value == null || prop.Value.Type == JTokenType.Null)
|
||||
accs.@params.Remove(prop.Name);
|
||||
else
|
||||
accs.@params[prop.Name] = prop.Value.Type == JTokenType.String
|
||||
? (prop.Value.Value<string>() ?? "")
|
||||
: prop.Value.Type == JTokenType.Integer
|
||||
? prop.Value.Value<long>()
|
||||
: prop.Value.Type == JTokenType.Float
|
||||
? prop.Value.Value<double>()
|
||||
: prop.Value.Type == JTokenType.Boolean
|
||||
? prop.Value.Value<bool>()
|
||||
: prop.Value.ToString(Formatting.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void ApplyAccsRemove(TelegramAccsProfile accs, string key)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "group":
|
||||
accs.group = null;
|
||||
break;
|
||||
case "ispasswd":
|
||||
case "is_passwd":
|
||||
case "passwd":
|
||||
accs.IsPasswd = null;
|
||||
break;
|
||||
case "ban":
|
||||
accs.ban = null;
|
||||
break;
|
||||
case "ban_msg":
|
||||
case "banmsg":
|
||||
accs.ban_msg = null;
|
||||
break;
|
||||
case "comment":
|
||||
accs.comment = null;
|
||||
break;
|
||||
case "ids":
|
||||
accs.ids = null;
|
||||
break;
|
||||
case "params":
|
||||
accs.@params = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Shared;
|
||||
using TelegramAuth.Models;
|
||||
|
||||
namespace TelegramAuth.Services
|
||||
{
|
||||
public partial class TelegramAuthStore
|
||||
{
|
||||
readonly TelegramAuthConf _conf;
|
||||
readonly string baseDir;
|
||||
readonly string usersPath;
|
||||
readonly string adminsPath;
|
||||
readonly string langsPath;
|
||||
public const int MaxActiveDevicesPerUser = 5;
|
||||
public const int AdminUnlimitedDevices = -1;
|
||||
|
||||
public TelegramAuthStore(TelegramAuthConf conf)
|
||||
{
|
||||
_conf = conf ?? new TelegramAuthConf();
|
||||
var rel = string.IsNullOrWhiteSpace(_conf.data_dir)
|
||||
? Path.Combine("database", "tgauth")
|
||||
: _conf.data_dir.Trim().TrimStart('/', '\\');
|
||||
baseDir = Path.IsPathRooted(rel)
|
||||
? rel
|
||||
: Path.Combine(AppContext.BaseDirectory, rel);
|
||||
usersPath = Path.Combine(baseDir, "users.json");
|
||||
adminsPath = Path.Combine(baseDir, "admins.json");
|
||||
langsPath = Path.Combine(baseDir, "user_langs.json");
|
||||
}
|
||||
|
||||
public void EnsureStorage()
|
||||
{
|
||||
Directory.CreateDirectory(baseDir);
|
||||
if (!File.Exists(usersPath)) File.WriteAllText(usersPath, "[]");
|
||||
if (!File.Exists(adminsPath)) File.WriteAllText(adminsPath, "[]");
|
||||
if (!File.Exists(langsPath)) File.WriteAllText(langsPath, "{}");
|
||||
}
|
||||
|
||||
public void EnsureOwnerUsersAtStartup()
|
||||
{
|
||||
var ids = _conf.owner_telegram_ids;
|
||||
if (ids == null || ids.Length == 0)
|
||||
return;
|
||||
|
||||
var users = GetUsers();
|
||||
var changed = false;
|
||||
var lang = string.IsNullOrWhiteSpace(_conf.auto_provision_lang) ? "ru" : _conf.auto_provision_lang.Trim();
|
||||
|
||||
foreach (var ownerNumericId in ids)
|
||||
{
|
||||
var telegramId = ownerNumericId.ToString(CultureInfo.InvariantCulture);
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, telegramId, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
{
|
||||
users.Add(new TelegramUserRecord
|
||||
{
|
||||
TelegramId = telegramId,
|
||||
TgUsername = "",
|
||||
Role = "admin",
|
||||
Lang = lang,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ApprovedBy = "owner-config",
|
||||
Disabled = false,
|
||||
Devices = new List<DeviceRecord>()
|
||||
});
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
user.Role = "admin";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (user.Disabled)
|
||||
{
|
||||
user.Disabled = false;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user.ApprovedBy))
|
||||
{
|
||||
user.ApprovedBy = "owner-config";
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
SaveUsers(users);
|
||||
}
|
||||
|
||||
public List<TelegramUserRecord> GetUsers()
|
||||
{
|
||||
EnsureStorage();
|
||||
return JsonConvert.DeserializeObject<List<TelegramUserRecord>>(File.ReadAllText(usersPath)) ?? new List<TelegramUserRecord>();
|
||||
}
|
||||
|
||||
public void SaveUsers(List<TelegramUserRecord> users)
|
||||
{
|
||||
EnsureStorage();
|
||||
File.WriteAllText(usersPath, JsonConvert.SerializeObject(users, Formatting.Indented));
|
||||
}
|
||||
|
||||
public Dictionary<string, string> GetLangs()
|
||||
{
|
||||
EnsureStorage();
|
||||
return JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(langsPath)) ?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public TelegramUserRecord? FindByUid(string uid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uid)) return null;
|
||||
return GetUsers().FirstOrDefault(u => u.Devices.Any(d => d.Active && string.Equals(d.Uid, uid, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
public TelegramUserRecord? FindByTelegramId(string telegramId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telegramId)) return null;
|
||||
return GetUsers().FirstOrDefault(u => string.Equals(u.TelegramId, telegramId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public static bool IsRegistrationPending(TelegramUserRecord? user) => user?.RegistrationPending == true;
|
||||
|
||||
public bool IsActive(TelegramUserRecord user)
|
||||
{
|
||||
if (user == null)
|
||||
return false;
|
||||
|
||||
if (IsRegistrationPending(user))
|
||||
return false;
|
||||
|
||||
if (user.Disabled)
|
||||
return false;
|
||||
|
||||
return !user.ExpiresAt.HasValue || user.ExpiresAt.Value.ToUniversalTime() >= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
bool ShouldSyncAccsdb() =>
|
||||
_conf.enable &&
|
||||
CoreInit.conf != null && CoreInit.conf.accsdb.enable;
|
||||
|
||||
void RemoveUidFromAccsdbIfSync(string deviceUid)
|
||||
{
|
||||
if (!ShouldSyncAccsdb() || string.IsNullOrWhiteSpace(deviceUid))
|
||||
return;
|
||||
AccsdbUidSync.RemoveUid(deviceUid.Trim());
|
||||
}
|
||||
|
||||
void BumpOldestActiveDeviceIfAtDeviceLimit(TelegramUserRecord user)
|
||||
{
|
||||
var maxDevices = GetMaxDevices(user);
|
||||
if (maxDevices <= 0 || user.Devices == null)
|
||||
return;
|
||||
|
||||
var activeDevices = user.Devices.Where(d => d.Active).OrderByDescending(d => d.LastSeenAt ?? d.LinkedAt).ToList();
|
||||
if (activeDevices.Count < maxDevices)
|
||||
return;
|
||||
|
||||
var oldestActive = activeDevices.Last();
|
||||
oldestActive.Active = false;
|
||||
RemoveUidFromAccsdbIfSync(oldestActive.Uid);
|
||||
}
|
||||
|
||||
DateTime ResolveAccsdbExpiresLocal(TelegramUserRecord user)
|
||||
{
|
||||
if (user.ExpiresAt.HasValue)
|
||||
{
|
||||
var v = user.ExpiresAt.Value;
|
||||
return v.Kind == DateTimeKind.Utc ? v.ToLocalTime() : v;
|
||||
}
|
||||
|
||||
var sd = CoreInit.conf?.accsdb?.shared_daytime ?? 0;
|
||||
var days = sd > 0 ? Math.Max(1, sd) : 365;
|
||||
return DateTime.Now.AddDays(days);
|
||||
}
|
||||
|
||||
void SyncDeviceToAccsdb(TelegramUserRecord user, string deviceUid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceUid) || user == null)
|
||||
return;
|
||||
|
||||
if (!ShouldSyncAccsdb())
|
||||
return;
|
||||
|
||||
var u = deviceUid.Trim();
|
||||
|
||||
if (user.Disabled || IsRegistrationPending(user))
|
||||
{
|
||||
AccsdbUidSync.RemoveUid(u);
|
||||
return;
|
||||
}
|
||||
|
||||
AccsdbUidSync.UpsertTelegramDevice(
|
||||
u,
|
||||
user,
|
||||
ResolveAccsdbExpiresLocal(user),
|
||||
_conf.accsdb_sync_group_admin,
|
||||
_conf.accsdb_sync_group_user);
|
||||
}
|
||||
|
||||
public void ResyncUserDevicesToAccsdb(TelegramUserRecord user)
|
||||
{
|
||||
if (user == null || !ShouldSyncAccsdb())
|
||||
return;
|
||||
|
||||
if (user.Disabled || IsRegistrationPending(user))
|
||||
{
|
||||
foreach (var d in user.Devices ?? Enumerable.Empty<DeviceRecord>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(d.Uid))
|
||||
AccsdbUidSync.RemoveUid(d.Uid.Trim());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var d in user.Devices ?? Enumerable.Empty<DeviceRecord>())
|
||||
{
|
||||
if (d.Active && !string.IsNullOrWhiteSpace(d.Uid))
|
||||
SyncDeviceToAccsdb(user, d.Uid.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
void SyncAccsdbForBind(TelegramUserRecord user, string? deviceUid)
|
||||
{
|
||||
if (!ShouldSyncAccsdb() || string.IsNullOrWhiteSpace(deviceUid))
|
||||
return;
|
||||
|
||||
var u = deviceUid.Trim();
|
||||
|
||||
if (user.Disabled || IsRegistrationPending(user))
|
||||
{
|
||||
AccsdbUidSync.RemoveUid(u);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsActive(user))
|
||||
return;
|
||||
|
||||
var dev = user.Devices?.FirstOrDefault(d => string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase));
|
||||
if (dev == null || !dev.Active)
|
||||
return;
|
||||
|
||||
SyncDeviceToAccsdb(user, u);
|
||||
}
|
||||
|
||||
const string RegistrationPendingApprovedByMarker = "registration-pending";
|
||||
|
||||
static bool LegacyApprovedByIsRegistrationPending(string? approvedBy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(approvedBy))
|
||||
return false;
|
||||
return string.Equals(approvedBy.Trim(), RegistrationPendingApprovedByMarker, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public ImportResult ImportFromLegacy(string legacyBasePath)
|
||||
{
|
||||
EnsureStorage();
|
||||
|
||||
var legacyTokensPath = Path.Combine(legacyBasePath, "tokens.json");
|
||||
var legacyAdminsPath = Path.Combine(legacyBasePath, "admin_ids.json");
|
||||
var legacyLangsPath = Path.Combine(legacyBasePath, "user_langs.json");
|
||||
|
||||
var users = new Dictionary<string, TelegramUserRecord>(StringComparer.Ordinal);
|
||||
var langs = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var result = new ImportResult();
|
||||
|
||||
if (File.Exists(legacyLangsPath))
|
||||
{
|
||||
langs = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(legacyLangsPath))
|
||||
?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
result.ImportedLangs = langs.Count;
|
||||
}
|
||||
|
||||
var adminIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (File.Exists(legacyAdminsPath))
|
||||
{
|
||||
var rawAdmins = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(File.ReadAllText(legacyAdminsPath))
|
||||
?? new List<Dictionary<string, object>>();
|
||||
|
||||
foreach (var row in rawAdmins)
|
||||
{
|
||||
if (row.TryGetValue("telegram_id", out var v) && v != null)
|
||||
adminIds.Add(v.ToString() ?? "");
|
||||
}
|
||||
result.ImportedAdmins = adminIds.Count;
|
||||
}
|
||||
|
||||
if (File.Exists(legacyTokensPath))
|
||||
{
|
||||
var raw = JsonConvert.DeserializeObject<List<LegacyTokenRecord>>(File.ReadAllText(legacyTokensPath))
|
||||
?? new List<LegacyTokenRecord>();
|
||||
|
||||
foreach (var item in raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.telegram_id))
|
||||
continue;
|
||||
|
||||
if (!users.TryGetValue(item.telegram_id, out var user))
|
||||
{
|
||||
var pending = LegacyApprovedByIsRegistrationPending(item.approved_by);
|
||||
var isAdmin = adminIds.Contains(item.telegram_id);
|
||||
if (isAdmin)
|
||||
pending = false;
|
||||
|
||||
var approvedBy = string.IsNullOrWhiteSpace(item.approved_by)
|
||||
? "legacy-import"
|
||||
: item.approved_by.Trim();
|
||||
|
||||
user = new TelegramUserRecord
|
||||
{
|
||||
TelegramId = item.telegram_id,
|
||||
TgUsername = item.tg_username ?? "",
|
||||
ApprovedBy = approvedBy,
|
||||
CreatedAt = item.created_at,
|
||||
ExpiresAt = item.expires_at,
|
||||
Lang = langs.TryGetValue(item.telegram_id, out var lang) ? lang : "ru",
|
||||
Role = isAdmin ? "admin" : "user",
|
||||
Disabled = pending,
|
||||
RegistrationPending = pending,
|
||||
Devices = new List<DeviceRecord>()
|
||||
};
|
||||
users[item.telegram_id] = user;
|
||||
}
|
||||
|
||||
foreach (var device in item.devices ?? new List<LegacyDeviceRecord>())
|
||||
{
|
||||
if (device == null || string.IsNullOrWhiteSpace(device.uid))
|
||||
continue;
|
||||
|
||||
if (user.Devices.Any(d => string.Equals(d.Uid, device.uid, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
DateTime linkedAt = item.created_at ?? DateTime.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(device.bound_at))
|
||||
DateTime.TryParse(device.bound_at, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out linkedAt);
|
||||
|
||||
DateTime? lastSeenAt = null;
|
||||
if (!string.IsNullOrWhiteSpace(device.last_seen) && DateTime.TryParse(device.last_seen, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsedLastSeen))
|
||||
lastSeenAt = parsedLastSeen;
|
||||
|
||||
user.Devices.Add(new DeviceRecord
|
||||
{
|
||||
Uid = device.uid,
|
||||
Name = device.label,
|
||||
LinkedAt = linkedAt,
|
||||
LastSeenAt = lastSeenAt,
|
||||
Active = true,
|
||||
Source = "legacy-import"
|
||||
});
|
||||
result.ImportedDevices++;
|
||||
}
|
||||
}
|
||||
|
||||
result.ImportedUsers = users.Count;
|
||||
}
|
||||
|
||||
SaveUsers(users.Values.OrderBy(u => u.TelegramId, StringComparer.Ordinal).ToList());
|
||||
File.WriteAllText(langsPath, JsonConvert.SerializeObject(langs, Formatting.Indented));
|
||||
File.WriteAllText(adminsPath, JsonConvert.SerializeObject(adminIds.OrderBy(x => x, StringComparer.Ordinal).ToList(), Formatting.Indented));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>library</OutputType>
|
||||
<IsPackable>true</IsPackable>
|
||||
<RootNamespace>TelegramAuth</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"TelegramAuth": {
|
||||
"enable": true,
|
||||
"data_dir": "database/tgauth",
|
||||
"legacy_import_path": "database/legacy_tgauth",
|
||||
"enable_import": true,
|
||||
"enable_cleanup": true,
|
||||
"mutations_api_secret": "GENERATE_A_LONG_RANDOM_SECRET",
|
||||
"owner_telegram_ids": [123456789],
|
||||
"auto_provision_users": false,
|
||||
"auto_provision_role": "user",
|
||||
"auto_provision_lang": "ru",
|
||||
"auto_provision_expires_days": 0,
|
||||
"auto_provision_activate_immediately": false,
|
||||
"accsdb_sync_group_admin": 100,
|
||||
"accsdb_sync_group_user": 0,
|
||||
"max_active_devices_per_user": 5,
|
||||
"limit_map": [
|
||||
{
|
||||
"pattern": "^/tg/auth",
|
||||
"map": {
|
||||
"limit": 25,
|
||||
"second": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"enable": true,
|
||||
"dynamic": true,
|
||||
"tree": [
|
||||
"Controllers",
|
||||
"Models",
|
||||
"Services",
|
||||
"ModInit.cs"
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
global using System;
|
||||
global using System.Globalization;
|
||||
global using System.Text;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Linq;
|
||||
global using System.Text.RegularExpressions;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
global using Microsoft.Extensions.Hosting;
|
||||
global using Newtonsoft.Json.Linq;
|
||||
global using Serilog;
|
||||
global using Telegram.Bot;
|
||||
global using Telegram.Bot.Types;
|
||||
global using Telegram.Bot.Types.Enums;
|
||||
global using Telegram.Bot.Types.ReplyMarkups;
|
||||
global using TelegramAuthBot.Models;
|
||||
@@ -1,55 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shared.Models.Events;
|
||||
using Shared.Models.Module;
|
||||
using Shared.Models.Module.Interfaces;
|
||||
using Shared.Services;
|
||||
using TelegramAuthBot.Models;
|
||||
using TelegramAuthBot.Services;
|
||||
|
||||
namespace TelegramAuthBot
|
||||
{
|
||||
public class ModInit : IModuleLoaded, IModuleConfigure
|
||||
{
|
||||
public static TelegramAuthBotConf conf = new();
|
||||
|
||||
public void Configure(ConfigureModel app)
|
||||
{
|
||||
SyncConfFromInit();
|
||||
app.services.AddHostedService<TelegramAuthBotHostedService>();
|
||||
}
|
||||
|
||||
public void Loaded(InitspaceModel initspace)
|
||||
{
|
||||
SyncConfFromInit();
|
||||
EventListener.UpdateInitFile += SyncConfFromInit;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
EventListener.UpdateInitFile -= SyncConfFromInit;
|
||||
}
|
||||
|
||||
static void SyncConfFromInit()
|
||||
{
|
||||
conf = ModuleInvoke.Init("TelegramAuthBot", DefaultConfTemplate());
|
||||
|
||||
if (conf.enable && string.IsNullOrWhiteSpace(conf.bot_token))
|
||||
{
|
||||
var msg = "TelegramAuthBot: enable=true, но bot_token пустой — проверьте секцию TelegramAuthBot в init.conf рядом с Core.dll.";
|
||||
Console.WriteLine(msg);
|
||||
TelegramAuthBotSerilog.Log.Warning("{Message}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
static TelegramAuthBotConf DefaultConfTemplate() =>
|
||||
new()
|
||||
{
|
||||
enable = true,
|
||||
bot_token = "",
|
||||
lampac_base_url = "http://127.0.0.1:9118",
|
||||
request_timeout_sec = 10,
|
||||
service_display_name = "Lampac NextGen Bot",
|
||||
mutations_api_secret = ""
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TelegramAuthBot.Models
|
||||
{
|
||||
public sealed class BindCompleteResult
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public bool PendingAdminApproval { get; set; }
|
||||
}
|
||||
|
||||
public class UserByTelegramDto
|
||||
{
|
||||
public bool found { get; set; }
|
||||
public string telegramId { get; set; }
|
||||
public string username { get; set; }
|
||||
public string role { get; set; }
|
||||
public string lang { get; set; }
|
||||
public bool disabled { get; set; }
|
||||
public bool registrationPending { get; set; }
|
||||
public bool active { get; set; }
|
||||
public string expiresAt { get; set; }
|
||||
public int deviceCount { get; set; }
|
||||
public int maxDevices { get; set; }
|
||||
}
|
||||
|
||||
public class DevicesResponseDto
|
||||
{
|
||||
public string telegramId { get; set; }
|
||||
public string username { get; set; }
|
||||
public List<DeviceDto> devices { get; set; }
|
||||
}
|
||||
|
||||
public class DeviceDto
|
||||
{
|
||||
public string uid { get; set; }
|
||||
public string name { get; set; }
|
||||
public bool active { get; set; }
|
||||
}
|
||||
|
||||
public class AdminUsersListResponseDto
|
||||
{
|
||||
public bool ok { get; set; }
|
||||
public List<AdminUserRowDto> users { get; set; }
|
||||
}
|
||||
|
||||
public class AdminUserAccsBriefDto
|
||||
{
|
||||
public int? group { get; set; }
|
||||
public bool? IsPasswd { get; set; }
|
||||
public bool? ban { get; set; }
|
||||
public string ban_msg { get; set; }
|
||||
public string comment { get; set; }
|
||||
public List<string> ids { get; set; }
|
||||
}
|
||||
|
||||
public class AdminUserRowDto
|
||||
{
|
||||
public string telegramId { get; set; }
|
||||
public string username { get; set; }
|
||||
public string role { get; set; }
|
||||
public bool disabled { get; set; }
|
||||
public bool registrationPending { get; set; }
|
||||
public bool active { get; set; }
|
||||
public string expiresAt { get; set; }
|
||||
public int deviceCount { get; set; }
|
||||
public AdminUserAccsBriefDto accs { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace TelegramAuthBot.Models
|
||||
{
|
||||
public class TelegramAuthBotConf
|
||||
{
|
||||
public bool enable { get; set; } = true;
|
||||
|
||||
public string bot_token { get; set; } = "";
|
||||
|
||||
public string lampac_base_url { get; set; } = "http://127.0.0.1:9118";
|
||||
|
||||
public int request_timeout_sec { get; set; } = 10;
|
||||
|
||||
public string service_display_name { get; set; } = "Lampac NextGen Bot";
|
||||
|
||||
public string mutations_api_secret { get; set; } = "";
|
||||
|
||||
public long[] admin_chat_ids { get; set; }
|
||||
|
||||
public long[] owner_telegram_ids { get; set; }
|
||||
|
||||
public bool notify_admins_on_pending_provision { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
# TelegramAuthBot
|
||||
|
||||
Фоновый **Telegram-бот** (long polling): привязка UID устройства к учётке в [TelegramAuth](../TelegramAuth/README.md), статус, устройства, админ-импорт/очистка через HTTP. Проект: **`Modules/Community/TelegramAuthBot/`**.
|
||||
|
||||
**Клиент Lampa** (замена `deny.js`, оверлей входа): [Community README](../README.md).
|
||||
|
||||
## Включение
|
||||
|
||||
1. В шаблоне [`config/base.conf`](../../../config/base.conf) **`TelegramAuthBot`** и **`TelegramAuth`** по умолчанию в **`BaseModule.SkipModules`** — уберите оба имени из списка, иначе модули не загрузятся.
|
||||
2. [`manifest.json`](manifest.json): **`"enable": true`**.
|
||||
3. `init.conf`: секция **`TelegramAuthBot`**. Пример: [`init.merge.example.json`](init.merge.example.json).
|
||||
|
||||
При `enable: true` и пустом `bot_token` в лог пишется предупреждение; long polling **не стартует**.
|
||||
|
||||
## Зависимости
|
||||
|
||||
1. **Модуль TelegramAuth** должен быть загружен и доступен по HTTP с того хоста/порта, который вы укажете в `lampac_base_url`.
|
||||
2. Профиль в TelegramAuth: либо уже есть запись, либо включён `auto_provision_users`, либо владелец попадает в базу при **старте Lampac** по `TelegramAuth.owner_telegram_ids` (см. [TelegramAuth](../TelegramAuth/README.md)).
|
||||
|
||||
## Конфигурация (`TelegramAuthBot`)
|
||||
|
||||
| Поле | Описание |
|
||||
| ---- | -------- |
|
||||
| `enable` | Включить бота. |
|
||||
| `bot_token` | Токен от [@BotFather](https://t.me/BotFather). |
|
||||
| `lampac_base_url` | Базовый URL Lampac **без** завершающего слэша, например `http://127.0.0.1:9118`. К нему добавляются пути вида `tg/auth/...`. |
|
||||
| `request_timeout_sec` | Таймаут HTTP-клиента к Lampac (минимум 1 сек). |
|
||||
| `service_display_name` | Отображаемое имя сервиса в текстах бота (HTML-экранирование учитывается в коде). |
|
||||
| `mutations_api_secret` | Тот же секрет, что **`TelegramAuth.mutations_api_secret`**. Нужен для админ-команд и их HTTP-вызовов (`X-TelegramAuth-Mutations-Secret`). Если пусто — бот сообщит об этом. |
|
||||
| `admin_chat_ids` | Если **не пустой** — админ-команды только из этих групп, **кроме** лички пользователей из `owner_telegram_ids`. Если **пустой** — из любого чата. |
|
||||
| `owner_telegram_ids` | Числовые **user** id (как в `TelegramAuth.owner_telegram_ids`). Нужны, если задан `admin_chat_ids`, но владелец хочет вызывать `/users` из лички. |
|
||||
| `notify_admins_on_pending_provision` | `true` по умолчанию. После успешной привязки UID, когда новый пользователь создан в режиме **ожидания подтверждения** (`auto_provision_activate_immediately: false`), бот рассылает администраторам (роль `admin` в базе) личные сообщения с кнопками «Принять» / «Отклонить`. Для запроса списка админов используется тот же API, что и `/users`; поэтому при **пустом** `mutations_api_secret` уведомления **не отправляются** (в лог пишется предупреждение). Установите `false`, чтобы отключить рассылку. |
|
||||
|
||||
## Как это работает
|
||||
|
||||
1. При старте регистрируется `HostedService`: проверка токена (`GetMe`), снятие webhook, цикл **`GetUpdates`** (long polling).
|
||||
2. Для каждого апдейта создаётся сессия с HTTP-клиентом к `lampac_base_url` ([`LampacTelegramAuthHttpClient`](Services/LampacTelegramAuthHttpClient.cs)).
|
||||
3. Привязка устройства: бот вызывает `GET .../tg/auth/user/by-telegram`. Если пользователь **найден**, но доступ **неактивен** (ожидание модерации, отключён админом или истёк срок), привязка **не выполняется** — пользователю отправляется пояснение. Если учётки **ещё нет** и включён `auto_provision_users`, привязка выполняется через `POST .../tg/auth/bind/complete` (создание пользователя и устройства на стороне API). Иначе — тот же `bind/complete` для уже существующей записи. При успешной регистрации с ожиданием подтверждения админам может уйти уведомление (см. **`notify_admins_on_pending_provision`**). Имя устройства в `users.json` клиент выставляет после входа (`POST .../tg/auth/device/name`) или пользователь — командой `/devicename` (см. [TelegramAuth](../TelegramAuth/README.md)).
|
||||
|
||||
**Важно:** процесс должен видеть Lampac по сети. В Docker часто нужен URL вида `http://host.docker.internal:9118` или имя сервиса compose, а не только `127.0.0.1`, если бот крутится в другом контейнере.
|
||||
|
||||
## Сценарий для пользователя
|
||||
|
||||
1. Открыть клиент Lampac, получить **UID** на экране авторизации.
|
||||
2. Написать боту в **личку** (в группах без `From` бот может не определить Telegram user id — см. сообщение об ошибке в коде).
|
||||
3. Отправить UID текстом **или** открыть deep link: `https://t.me/<bot>?start=<uid>` (UID: 6–20 символов, `[a-zA-Z0-9_-]`).
|
||||
4. В клиенте нажать «Проверить снова» (или аналог), который дергает `GET /tg/auth/status`.
|
||||
|
||||
Кнопки меню: **Мой статус**, **Мои устройства**, **Помощь**.
|
||||
|
||||
## Команды
|
||||
|
||||
| Команда | Кто | Описание |
|
||||
| ------- | --- | -------- |
|
||||
| `/start` | Все | Приветствие и инструкция; вариант `/start <uid>` — быстрая привязка. |
|
||||
| `/help` | Все | Подсказка по входу и кнопкам. |
|
||||
| `/me` | Все | Профиль из API (роль, срок, число устройств, лимит). |
|
||||
| `/devices` | Все | Список устройств; для активных — **Отвязать**, для отключённых — **✅ Включить** (снова активирует UID на сервере). |
|
||||
| `/devicename` | Все | `/devicename <uid> <имя>` — имя в `Devices[].Name` через `POST /tg/auth/device/name` (только **свой** активный UID). Вместо имени `-` сбрасывает подпись. Секрет мутаций не нужен. |
|
||||
| `/users` | Админы TelegramAuth | Список пользователей (постранично); в строке кратко видно **`accs`** (группа, `ban`, …). Ожидающие — **Принять** / **Отклонить**; остальные — **Выкл** / **Вкл**. |
|
||||
| `/user` | Админы | `/user <telegramId>` — карточка, JSON **`accs`**, устройства (`GET /tg/auth/admin/user`). |
|
||||
| `/setuser` | Админы | Правка профиля и **`accs`** через `POST /tg/auth/admin/user/patch` (срок, роль, `group`, `ban`, `banmsg`, `comment`, `ids`, `param`, сброс полей — см. подсказку без аргументов). |
|
||||
| `/import` | Админы TelegramAuth | `POST /tg/auth/import` (секрет + роль `admin` + при необходимости чат из `admin_chat_ids`). |
|
||||
| `/cleanup` | Админы TelegramAuth | `POST /tg/auth/devices/cleanup`. |
|
||||
|
||||
Админ определяется по полю **роли** в ответе API пользователя (`role == admin`), не по списку `admin_chat_ids` (этот список только ограничивает **чаты**, не роли).
|
||||
|
||||
## Ссылки на код
|
||||
|
||||
- Регистрация сервиса: [`ModInit.cs`](ModInit.cs) (`IModuleConfigure` → `AddHostedService<TelegramAuthBotHostedService>`).
|
||||
- Long polling и жизненный цикл: [`TelegramAuthBotHostedService.cs`](Services/TelegramAuthBotHostedService.cs).
|
||||
- Диалоги и команды: [`TelegramAuthBotSession.cs`](Services/TelegramAuthBotSession.cs).
|
||||
|
||||
## Устранение неполадок
|
||||
|
||||
| Симптом | Что проверить |
|
||||
| ------- | ------------- |
|
||||
| Бот молчит при старте | `enable`, `bot_token`, сеть до `api.telegram.org`, логи Serilog / консоль. |
|
||||
| «Тебя нет в базе» | Запись пользователя с вашим Telegram ID в `users.json` (TelegramAuth), импорт или ручное добавление. |
|
||||
| Привязка не срабатывает | Lampac доступен с хоста бота по `lampac_base_url`; пользователь `active`; лимит устройств не превышен. |
|
||||
| `/import` или `/cleanup` не работают | Непустой `mutations_api_secret` (бот не подставляет root-cookie); значение совпадает с `TelegramAuth.mutations_api_secret`; роль `admin`; если задан `admin_chat_ids` — команда из этой группы или из лички при наличии твоего id в `owner_telegram_ids`. |
|
||||
| Админы не получили уведомление о новой заявке | Включён `notify_admins_on_pending_provision`; задан ненулевой `mutations_api_secret`; в `users.json` есть хотя бы один пользователь с ролью `admin`. |
|
||||
|
||||
Подробное описание HTTP API см. в [README модуля TelegramAuth](../TelegramAuth/README.md).
|
||||
@@ -1,185 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using TelegramAuthBot.Models;
|
||||
|
||||
namespace TelegramAuthBot.Services
|
||||
{
|
||||
sealed class LampacTelegramAuthHttpClient : IDisposable
|
||||
{
|
||||
public const string MutationsSecretHeaderName = "X-TelegramAuth-Mutations-Secret";
|
||||
|
||||
readonly HttpClient _http;
|
||||
readonly string _mutationsSecret;
|
||||
|
||||
public LampacTelegramAuthHttpClient(TelegramAuthBotConf conf)
|
||||
{
|
||||
var baseUrl = (conf.lampac_base_url ?? "").Trim().TrimEnd('/') + "/";
|
||||
_http = new HttpClient { BaseAddress = new Uri(baseUrl, UriKind.Absolute) };
|
||||
_http.Timeout = TimeSpan.FromSeconds(Math.Max(1, conf.request_timeout_sec));
|
||||
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_mutationsSecret = conf.mutations_api_secret?.Trim() ?? "";
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
|
||||
public async Task<UserByTelegramDto> GetUserByTelegramAsync(string telegramId, CancellationToken ct)
|
||||
{
|
||||
var path = "tg/auth/user/by-telegram?" + Uri.EscapeDataString("telegramId") + "=" + Uri.EscapeDataString(telegramId);
|
||||
using var resp = await _http.GetAsync(path, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return null;
|
||||
return JsonConvert.DeserializeObject<UserByTelegramDto>(body);
|
||||
}
|
||||
|
||||
public async Task<DevicesResponseDto> GetDevicesAsync(string telegramId, CancellationToken ct)
|
||||
{
|
||||
var path = "tg/auth/devices?" + Uri.EscapeDataString("telegramId") + "=" + Uri.EscapeDataString(telegramId);
|
||||
using var resp = await _http.GetAsync(path, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (resp.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return null;
|
||||
return JsonConvert.DeserializeObject<DevicesResponseDto>(body);
|
||||
}
|
||||
|
||||
public async Task<BindCompleteResult> BindCompleteAsync(string uid, string telegramId, string username, CancellationToken ct)
|
||||
{
|
||||
var payload = new { uid, telegramId, username };
|
||||
using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/bind/complete") { Content = content };
|
||||
AddMutationsSecret(req);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return new BindCompleteResult();
|
||||
var jo = JObject.Parse(body);
|
||||
return new BindCompleteResult
|
||||
{
|
||||
Ok = jo.Value<bool?>("ok") == true,
|
||||
PendingAdminApproval = jo.Value<bool?>("pendingAdminApproval") == true
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> UnbindDeviceAsync(string telegramId, string uid, CancellationToken ct)
|
||||
{
|
||||
var payload = new { telegramId, uid };
|
||||
using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
|
||||
using var resp = await _http.PostAsync("tg/auth/device/unbind", content, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return false;
|
||||
var jo = JObject.Parse(body);
|
||||
return jo.Value<bool?>("ok") == true;
|
||||
}
|
||||
|
||||
public async Task<(bool ok, string detail)> ReactivateDeviceAsync(string telegramId, string uid, CancellationToken ct)
|
||||
{
|
||||
var payload = new { telegramId, uid };
|
||||
using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
|
||||
using var resp = await _http.PostAsync("tg/auth/device/reactivate", content, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return resp.IsSuccessStatusCode ? (true, body) : (false, body);
|
||||
}
|
||||
|
||||
public async Task<(bool ok, string detail)> SetDeviceDisplayNameAsync(string uid, string name, CancellationToken ct)
|
||||
{
|
||||
var payload = new { uid, name };
|
||||
using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
|
||||
using var resp = await _http.PostAsync("tg/auth/device/name", content, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return resp.IsSuccessStatusCode ? (true, body) : (false, body);
|
||||
}
|
||||
|
||||
public async Task<(bool ok, string detail)> ImportLegacyAsync(CancellationToken ct)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/import");
|
||||
AddMutationsSecret(req);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return resp.IsSuccessStatusCode ? (true, body) : (false, body);
|
||||
}
|
||||
|
||||
public async Task<(bool ok, string detail)> CleanupDevicesAsync(CancellationToken ct)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/devices/cleanup");
|
||||
AddMutationsSecret(req);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return resp.IsSuccessStatusCode ? (true, body) : (false, body);
|
||||
}
|
||||
|
||||
public async Task<AdminUsersListResponseDto> GetAdminUsersAsync(CancellationToken ct)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "tg/auth/admin/users");
|
||||
AddMutationsSecret(req);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return null;
|
||||
return JsonConvert.DeserializeObject<AdminUsersListResponseDto>(body);
|
||||
}
|
||||
|
||||
public async Task<(bool ok, string detail)> SetUserDisabledAsync(string telegramId, bool disabled, CancellationToken ct)
|
||||
{
|
||||
var payload = new { telegramId, disabled };
|
||||
using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/admin/user/disabled") { Content = content };
|
||||
AddMutationsSecret(req);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return resp.IsSuccessStatusCode ? (true, body) : (false, body);
|
||||
}
|
||||
|
||||
public async Task<(bool ok, string detail)> ResolveRegistrationPendingAsync(string telegramId, bool approve, CancellationToken ct)
|
||||
{
|
||||
var payload = new { telegramId, approve };
|
||||
using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/admin/user/pending") { Content = content };
|
||||
AddMutationsSecret(req);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return resp.IsSuccessStatusCode ? (true, body) : (false, body);
|
||||
}
|
||||
|
||||
public async Task<(bool ok, JObject data, string errorBody)> GetAdminUserDetailAsync(string telegramId, CancellationToken ct)
|
||||
{
|
||||
var path = "tg/auth/admin/user?" + Uri.EscapeDataString("telegramId") + "=" + Uri.EscapeDataString(telegramId);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
AddMutationsSecret(req);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return (false, null, body);
|
||||
try
|
||||
{
|
||||
return (true, JObject.Parse(body), body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, null, body);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool ok, string detail)> PatchAdminUserAsync(JObject patch, CancellationToken ct)
|
||||
{
|
||||
using var content = new StringContent(patch.ToString(Formatting.None), Encoding.UTF8, "application/json");
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/admin/user/patch") { Content = content };
|
||||
AddMutationsSecret(req);
|
||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
return resp.IsSuccessStatusCode ? (true, body) : (false, body);
|
||||
}
|
||||
|
||||
void AddMutationsSecret(HttpRequestMessage req)
|
||||
{
|
||||
if (_mutationsSecret.Length > 0)
|
||||
req.Headers.TryAddWithoutValidation(MutationsSecretHeaderName, _mutationsSecret);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
using TelegramAuthBot.Models;
|
||||
|
||||
namespace TelegramAuthBot.Services
|
||||
{
|
||||
public sealed class TelegramAuthBotHostedService : BackgroundService
|
||||
{
|
||||
const int GetUpdatesLimit = 100;
|
||||
const int GetUpdatesTimeoutSeconds = 50;
|
||||
static readonly TimeSpan GetUpdatesErrorDelay = TimeSpan.FromSeconds(5);
|
||||
|
||||
public TelegramAuthBotHostedService()
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunBotAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}", "id_tgauthbot_execute");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
async Task RunBotAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var conf = ModInit.conf;
|
||||
if (!conf.enable || string.IsNullOrWhiteSpace(conf.bot_token))
|
||||
{
|
||||
var msg = "TelegramAuthBot: отключён (enable=false или пустой bot_token); long polling не запущен.";
|
||||
Console.WriteLine(msg);
|
||||
TelegramAuthBotSerilog.Log.Information("{Message}", msg);
|
||||
return;
|
||||
}
|
||||
|
||||
using var api = new LampacTelegramAuthHttpClient(conf);
|
||||
var bot = new TelegramBotClient(conf.bot_token.Trim());
|
||||
var displayName = conf.service_display_name ?? "Lampac NextGen Bot";
|
||||
|
||||
try
|
||||
{
|
||||
var me = await bot.GetMe(stoppingToken).ConfigureAwait(false);
|
||||
var line = $"TelegramAuthBot: @{me.Username} — Telegram API OK; TelegramAuth HTTP {conf.lampac_base_url}";
|
||||
Console.WriteLine(line);
|
||||
TelegramAuthBotSerilog.Log.Information("Telegram OK @{BotUsername}; TelegramAuth {BaseUrl}", me.Username, conf.lampac_base_url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("TelegramAuthBot: GetMe не удался (токен или сеть до api.telegram.org). " + ex.Message);
|
||||
TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}", "id_tgauthbot_getme");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await bot.DeleteWebhook(dropPendingUpdates: false, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}", "id_tgauthbot_deletewebhook");
|
||||
}
|
||||
|
||||
var session = new TelegramAuthBotSession(api, displayName);
|
||||
var pollMsg =
|
||||
$"TelegramAuthBot: long polling GetUpdates (limit {GetUpdatesLimit}, timeout {GetUpdatesTimeoutSeconds}s).";
|
||||
Console.WriteLine(pollMsg);
|
||||
TelegramAuthBotSerilog.Log.Information(
|
||||
"Long polling GetUpdates limit {Limit} timeout {TimeoutSeconds}s",
|
||||
GetUpdatesLimit, GetUpdatesTimeoutSeconds);
|
||||
|
||||
await PollUpdatesAsync(bot, session, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task PollUpdatesAsync(ITelegramBotClient bot, TelegramAuthBotSession session, CancellationToken stoppingToken)
|
||||
{
|
||||
int? offset = null;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
Update[] updates;
|
||||
try
|
||||
{
|
||||
updates = await bot.GetUpdates(
|
||||
offset,
|
||||
limit: GetUpdatesLimit,
|
||||
timeout: GetUpdatesTimeoutSeconds,
|
||||
allowedUpdates: null,
|
||||
cancellationToken: stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}, DelaySeconds={Delay}", "id_tgauthbot_getupdates", GetUpdatesErrorDelay.TotalSeconds);
|
||||
try
|
||||
{
|
||||
await Task.Delay(GetUpdatesErrorDelay, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var update in updates)
|
||||
{
|
||||
offset = update.Id + 1;
|
||||
try
|
||||
{
|
||||
await session.HandleUpdateAsync(bot, update, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}, UpdateId={UpdateId}", "id_tgauthbot_update", update.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace TelegramAuthBot.Services
|
||||
{
|
||||
internal static class TelegramAuthBotSerilog
|
||||
{
|
||||
public static readonly Serilog.ILogger Log =
|
||||
Serilog.Log.ForContext("SourceContext", "TelegramAuthBot");
|
||||
}
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
using Newtonsoft.Json;
|
||||
using Telegram.Bot.Types;
|
||||
using TelegramAuthBot.Models;
|
||||
|
||||
namespace TelegramAuthBot.Services
|
||||
{
|
||||
sealed partial class TelegramAuthBotSession
|
||||
{
|
||||
static bool IsTelegramAuthAdmin(UserByTelegramDto user) =>
|
||||
user != null && user.found && string.Equals(user.role, "admin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static bool IsAllowedAdminCommandContext(TelegramAuthBotConf conf, ChatType chatType, long chatId, long actorTelegramUserId)
|
||||
{
|
||||
var ids = conf.admin_chat_ids;
|
||||
if (ids == null || ids.Length == 0)
|
||||
return true;
|
||||
|
||||
if (ids.Contains(chatId))
|
||||
return true;
|
||||
|
||||
if (chatType == ChatType.Private)
|
||||
{
|
||||
var owners = conf.owner_telegram_ids;
|
||||
if (owners != null && owners.Length > 0 && owners.Contains(actorTelegramUserId))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async Task<bool> TryEnsureAdminMutationAccessAsync(ITelegramBotClient bot, Chat chat, string tgId, CancellationToken ct)
|
||||
{
|
||||
var conf = ModInit.conf;
|
||||
if (string.IsNullOrEmpty(conf.mutations_api_secret))
|
||||
{
|
||||
await bot.SendMessage(chat.Id,
|
||||
"В конфиге бота не задан <code>mutations_api_secret</code> — тот же секрет, что <code>TelegramAuth.mutations_api_secret</code> в init.conf.",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!long.TryParse(tgId?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var actorId))
|
||||
actorId = 0;
|
||||
|
||||
if (!IsAllowedAdminCommandContext(conf, chat.Type, chat.Id, actorId))
|
||||
{
|
||||
await bot.SendMessage(chat.Id,
|
||||
"Админ-команды с этого чата запрещены. Из лички: добавь свой id в <code>owner_telegram_ids</code> бота (те же числа, что <code>TelegramAuth.owner_telegram_ids</code>) или пиши из чата <code>admin_chat_ids</code>. Пустой <code>admin_chat_ids</code> — админ-команды из любого чата, в т.ч. лички.",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false);
|
||||
if (!IsTelegramAuthAdmin(user))
|
||||
{
|
||||
await bot.SendMessage(chat.Id,
|
||||
"Команда только для администраторов (роль admin в базе TelegramAuth). Остальные пользователи входят через UID; ты включаешь их в <code>/users</code>.",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async Task<bool> TryEnsureAdminForCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct)
|
||||
{
|
||||
var chat = cq.Message?.Chat;
|
||||
var chatId = chat?.Id ?? cq.From.Id;
|
||||
var conf = ModInit.conf;
|
||||
if (string.IsNullOrEmpty(conf.mutations_api_secret))
|
||||
{
|
||||
await bot.SendMessage(chatId,
|
||||
"В конфиге бота не задан <code>mutations_api_secret</code> — тот же секрет, что <code>TelegramAuth.mutations_api_secret</code> в init.conf.",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
var chatType = chat?.Type ?? ChatType.Private;
|
||||
if (!IsAllowedAdminCommandContext(conf, chatType, chatId, cq.From.Id))
|
||||
{
|
||||
await bot.SendMessage(chatId,
|
||||
"Админ-команды с этого чата запрещены. Для лички добавь свой id в <code>owner_telegram_ids</code> или используй чат из <code>admin_chat_ids</code>.",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
var tgId = cq.From.Id.ToString();
|
||||
var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false);
|
||||
if (!IsTelegramAuthAdmin(user))
|
||||
{
|
||||
await bot.SendMessage(chatId,
|
||||
"Команда только для администраторов (роль admin в базе TelegramAuth).",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static string ShortUserLabel(AdminUserRowDto u)
|
||||
{
|
||||
var n = string.IsNullOrEmpty(u.username) ? u.telegramId : u.username;
|
||||
if (n.Length > 14)
|
||||
n = n.Substring(0, 12) + "…";
|
||||
return n;
|
||||
}
|
||||
|
||||
static (string text, InlineKeyboardMarkup markup) BuildAdminUsersPage(AdminUsersListResponseDto data, int page, string actorTgId)
|
||||
{
|
||||
var all = data.users ?? new List<AdminUserRowDto>();
|
||||
var total = all.Count;
|
||||
var totalPages = total == 0 ? 1 : (total + AdminUsersPageSize - 1) / AdminUsersPageSize;
|
||||
if (page < 0) page = 0;
|
||||
if (page >= totalPages) page = totalPages - 1;
|
||||
|
||||
var slice = all.Skip(page * AdminUsersPageSize).Take(AdminUsersPageSize).ToList();
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"<b>Пользователи TelegramAuth</b> · всего {total} · стр. {page + 1}/{totalPages}\n"
|
||||
};
|
||||
|
||||
foreach (var u in slice)
|
||||
{
|
||||
var adm = string.Equals(u.role, "admin", StringComparison.OrdinalIgnoreCase);
|
||||
var st = u.registrationPending
|
||||
? "⏳ ждёт подтв."
|
||||
: u.disabled ? "🔒 отключён" : u.active ? "✅ доступ" : "⏸ нет доступа";
|
||||
var tag = string.IsNullOrEmpty(u.username) ? "—" : "@" + EscapeHtml(u.username);
|
||||
var accsHint = "";
|
||||
if (u.accs != null)
|
||||
{
|
||||
var bits = new List<string>();
|
||||
if (u.accs.group.HasValue)
|
||||
bits.Add($"g:{u.accs.group.Value}");
|
||||
if (u.accs.ban == true)
|
||||
bits.Add("ban");
|
||||
if (u.accs.IsPasswd == true)
|
||||
bits.Add("passwd");
|
||||
if (!string.IsNullOrEmpty(u.accs.comment))
|
||||
bits.Add(TruncatePlain(u.accs.comment, 18));
|
||||
if (bits.Count > 0)
|
||||
accsHint = " · <i>" + EscapeHtml(string.Join(", ", bits)) + "</i>";
|
||||
}
|
||||
|
||||
lines.Add($"{tag} · <code>{EscapeHtml(u.telegramId)}</code> · {st}{(adm ? " · <b>admin</b>" : "")}{accsHint}");
|
||||
}
|
||||
|
||||
if (total == 0)
|
||||
lines.Add("\n<i>Записей пока нет.</i>");
|
||||
|
||||
var rows = new List<InlineKeyboardButton[]>();
|
||||
foreach (var u in slice)
|
||||
{
|
||||
var isAdmin = string.Equals(u.role, "admin", StringComparison.OrdinalIgnoreCase);
|
||||
var self = string.Equals(u.telegramId, actorTgId, StringComparison.Ordinal);
|
||||
if (isAdmin || self)
|
||||
continue;
|
||||
|
||||
var label = ShortUserLabel(u);
|
||||
if (u.registrationPending)
|
||||
{
|
||||
var cbA = $"{CbApprovePending}{page}|{u.telegramId}";
|
||||
var cbR = $"{CbRejectPending}{page}|{u.telegramId}";
|
||||
if (Encoding.UTF8.GetByteCount(cbA) <= 64 && Encoding.UTF8.GetByteCount(cbR) <= 64)
|
||||
{
|
||||
rows.Add(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData($"✅ Принять · {label}", cbA),
|
||||
InlineKeyboardButton.WithCallbackData($"❌ Отклонить · {label}", cbR)
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var cb = u.disabled
|
||||
? $"{CbEnableUser}{page}|{u.telegramId}"
|
||||
: $"{CbDisableUser}{page}|{u.telegramId}";
|
||||
if (Encoding.UTF8.GetByteCount(cb) <= 64)
|
||||
{
|
||||
var title = u.disabled ? $"✅ Вкл · {label}" : $"🚫 Выкл · {label}";
|
||||
rows.Add(new[] { InlineKeyboardButton.WithCallbackData(title, cb) });
|
||||
}
|
||||
}
|
||||
|
||||
if (totalPages > 1)
|
||||
{
|
||||
var nav = new List<InlineKeyboardButton>();
|
||||
if (page > 0)
|
||||
nav.Add(InlineKeyboardButton.WithCallbackData("◀️", CbUsersPage + (page - 1)));
|
||||
if (page < totalPages - 1)
|
||||
nav.Add(InlineKeyboardButton.WithCallbackData("▶️", CbUsersPage + (page + 1)));
|
||||
if (nav.Count > 0)
|
||||
rows.Add(nav.ToArray());
|
||||
}
|
||||
|
||||
var markup = rows.Count > 0 ? new InlineKeyboardMarkup(rows) : null;
|
||||
return (string.Join("\n", lines), markup);
|
||||
}
|
||||
|
||||
async Task SendOrEditAdminUsersPageAsync(ITelegramBotClient bot, ChatId chatId, int? messageId, AdminUsersListResponseDto data, int page, string actorTgId, CancellationToken ct)
|
||||
{
|
||||
var (text, markup) = BuildAdminUsersPage(data, page, actorTgId);
|
||||
if (messageId.HasValue)
|
||||
{
|
||||
await bot.EditMessageText(chatId, messageId.Value, text, parseMode: ParseMode.Html, replyMarkup: markup, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await bot.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: markup, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
async Task CmdUsersAsync(ITelegramBotClient bot, Chat chat, string tgId, CancellationToken ct)
|
||||
{
|
||||
if (!await TryEnsureAdminMutationAccessAsync(bot, chat, tgId, ct).ConfigureAwait(false))
|
||||
return;
|
||||
|
||||
var data = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false);
|
||||
if (data == null || !data.ok)
|
||||
{
|
||||
await bot.SendMessage(chat.Id, "❌ Не удалось загрузить список пользователей (проверь секрет и доступ к Lampac).", cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOrEditAdminUsersPageAsync(bot, chat.Id, null, data, 0, tgId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static string TruncatePlain(string s, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s) || s.Length <= max)
|
||||
return s ?? "";
|
||||
return s.Substring(0, max) + "…";
|
||||
}
|
||||
|
||||
async Task CmdAdminUserDetailAsync(ITelegramBotClient bot, Chat chat, string actorTgId, string text, CancellationToken ct)
|
||||
{
|
||||
if (!await TryEnsureAdminMutationAccessAsync(bot, chat, actorTgId, ct).ConfigureAwait(false))
|
||||
return;
|
||||
|
||||
var m = AdminUserDetailRe.Match(text.Trim());
|
||||
if (!m.Success)
|
||||
{
|
||||
await bot.SendMessage(chat.Id,
|
||||
"<b>Просмотр пользователя (accsdb-поля)</b>\n\n<code>/user <telegramId></code>\n\nПоказывает <b>accs</b>, срок <b>expiresAt</b>, устройства. Редактирование — <code>/setuser</code>.",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var targetId = m.Groups[1].Value;
|
||||
var (ok, jo, errBody) = await _api.GetAdminUserDetailAsync(targetId, ct).ConfigureAwait(false);
|
||||
if (!ok || jo == null)
|
||||
{
|
||||
await bot.SendMessage(chat.Id,
|
||||
"❌ Не удалось загрузить пользователя.\n" + TruncateForTelegram(StripJsonError(errBody), 800),
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var role = jo.Value<string>("role") ?? "—";
|
||||
var lang = jo.Value<string>("lang") ?? "—";
|
||||
var uname = jo.Value<string>("username");
|
||||
var active = jo.Value<bool?>("active") == true;
|
||||
var disabled = jo.Value<bool?>("disabled") == true;
|
||||
var pending = jo.Value<bool?>("registrationPending") == true;
|
||||
var exp = jo["expiresAt"]?.ToString() ?? "—";
|
||||
var maxDev = jo.Value<int?>("maxDevices");
|
||||
var devCount = jo.Value<int?>("deviceCount");
|
||||
var maxStr = maxDev == -1 ? "∞" : (maxDev?.ToString() ?? "—");
|
||||
|
||||
var accsJson = jo["accs"] is JToken at && at.Type != JTokenType.Null
|
||||
? at.ToString(Formatting.Indented)
|
||||
: "(нет — действуют значения по умолчанию из роли и init)";
|
||||
|
||||
var devLines = new List<string>();
|
||||
if (jo["devices"] is JArray arr)
|
||||
{
|
||||
foreach (var d in arr.Take(12))
|
||||
{
|
||||
var uid = d.Value<string>("uid") ?? "";
|
||||
var nm = d.Value<string>("name");
|
||||
var act = d.Value<bool?>("active") == true;
|
||||
var nmShow = string.IsNullOrEmpty(nm) ? "—" : nm;
|
||||
devLines.Add($"• <code>{EscapeHtml(uid)}</code> · {EscapeHtml(nmShow)} · {(act ? "on" : "off")}");
|
||||
}
|
||||
|
||||
if (arr.Count > 12)
|
||||
devLines.Add($"… ещё {arr.Count - 12}");
|
||||
}
|
||||
|
||||
var devBlock = devLines.Count > 0 ? string.Join("\n", devLines) : "<i>нет устройств</i>";
|
||||
|
||||
var msg =
|
||||
$"<b>Пользователь</b> <code>{EscapeHtml(targetId)}</code>\n" +
|
||||
$"@{EscapeHtml(string.IsNullOrEmpty(uname) ? "—" : uname)} · роль <b>{EscapeHtml(role)}</b> · lang {EscapeHtml(lang)}\n" +
|
||||
$"активен: {(active ? "да" : "нет")} · отключён: {(disabled ? "да" : "нет")} · ожидание: {(pending ? "да" : "нет")}\n" +
|
||||
$"<b>Срок (ExpiresAt):</b> <code>{EscapeHtml(exp)}</code>\n" +
|
||||
$"<b>Устройства:</b> {devCount ?? 0} / {maxStr}\n\n" +
|
||||
"<b>accs</b> (синхронизируется в корневой users.json):\n<pre>" + EscapeHtml(accsJson) + "</pre>\n\n" +
|
||||
"<b>Устройства:</b>\n" + devBlock;
|
||||
|
||||
await bot.SendMessage(chat.Id, msg, parseMode: ParseMode.Html, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task CmdAdminSetUserAsync(ITelegramBotClient bot, Chat chat, string actorTgId, string text, CancellationToken ct)
|
||||
{
|
||||
if (!await TryEnsureAdminMutationAccessAsync(bot, chat, actorTgId, ct).ConfigureAwait(false))
|
||||
return;
|
||||
|
||||
var m = AdminSetUserRe.Match(text.Trim());
|
||||
if (!m.Success)
|
||||
{
|
||||
await bot.SendMessage(chat.Id,
|
||||
"<b>Изменение пользователя (accsdb)</b>\n\n" +
|
||||
"<code>/setuser <telegramId> <команда> …</code>\n\n" +
|
||||
"<b>Команды:</b>\n" +
|
||||
"• <code>group</code> <n> или <code>clear</code>\n" +
|
||||
"• <code>expires</code> <дата ISO или yyyy-MM-dd> или <code>clear</code>\n" +
|
||||
"• <code>role</code> user | admin\n" +
|
||||
"• <code>lang</code> <код>\n" +
|
||||
"• <code>comment</code> <текст>\n" +
|
||||
"• <code>banmsg</code> <текст>\n" +
|
||||
"• <code>passwd</code> on | off\n" +
|
||||
"• <code>ban</code> on | off — бан в accsdb (учётка TG активна); текст — <code>banmsg</code>\n" +
|
||||
"• <code>ids</code> uid1,uid2 или <code>clear</code>\n" +
|
||||
"• <code>param</code> ключ=значение\n" +
|
||||
"• <code>clearparam</code> ключ\n" +
|
||||
"• <code>clear</code> group ban ban_msg comment … — сброс полей accs\n\n" +
|
||||
"Пока аккаунт <b>ожидает подтверждения</b>, UID <b>не попадает</b> в accsdb (при привязке лишний UID удаляется из users.json).\n\n" +
|
||||
"После изменения при включённом sync данные уходят в корневой <code>users.json</code>.",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var targetId = m.Groups[1].Value;
|
||||
var sub = m.Groups[2].Value.Trim().ToLowerInvariant();
|
||||
var rest = m.Groups[3].Value.Trim();
|
||||
|
||||
var patch = new JObject { ["telegramId"] = targetId };
|
||||
string err;
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "group":
|
||||
if (string.IsNullOrEmpty(rest))
|
||||
err = "Укажи число или clear.";
|
||||
else if (rest.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
patch["accsRemove"] = new JArray("group");
|
||||
err = null;
|
||||
}
|
||||
else if (int.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var g))
|
||||
{
|
||||
patch["accs"] = new JObject { ["group"] = g };
|
||||
err = null;
|
||||
}
|
||||
else
|
||||
err = "group: нужно целое число или clear.";
|
||||
break;
|
||||
|
||||
case "expires":
|
||||
if (string.IsNullOrEmpty(rest))
|
||||
err = "Укажи дату или clear.";
|
||||
else if (rest.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
patch["expiresAt"] = JValue.CreateNull();
|
||||
err = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
patch["expiresAt"] = rest;
|
||||
err = null;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "role":
|
||||
if (!string.Equals(rest, "user", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(rest, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
err = "role: user или admin.";
|
||||
else
|
||||
{
|
||||
patch["role"] = rest.ToLowerInvariant() == "admin" ? "admin" : "user";
|
||||
err = null;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "lang":
|
||||
if (string.IsNullOrEmpty(rest))
|
||||
err = "Укажи код языка.";
|
||||
else
|
||||
{
|
||||
patch["lang"] = rest;
|
||||
err = null;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "comment":
|
||||
patch["accs"] = new JObject { ["comment"] = rest };
|
||||
err = null;
|
||||
break;
|
||||
|
||||
case "banmsg":
|
||||
patch["accs"] = new JObject { ["ban_msg"] = rest };
|
||||
err = null;
|
||||
break;
|
||||
|
||||
case "passwd":
|
||||
{
|
||||
var p = rest.ToLowerInvariant();
|
||||
if (p is "on" or "1" or "true" or "yes")
|
||||
{
|
||||
patch["accs"] = new JObject { ["IsPasswd"] = true };
|
||||
err = null;
|
||||
}
|
||||
else if (p is "off" or "0" or "false" or "no")
|
||||
{
|
||||
patch["accs"] = new JObject { ["IsPasswd"] = false };
|
||||
err = null;
|
||||
}
|
||||
else
|
||||
err = "passwd: on или off.";
|
||||
break;
|
||||
}
|
||||
|
||||
case "ban":
|
||||
{
|
||||
var p = rest.ToLowerInvariant();
|
||||
if (p is "on" or "1" or "true" or "yes")
|
||||
{
|
||||
patch["accs"] = new JObject { ["ban"] = true };
|
||||
err = null;
|
||||
}
|
||||
else if (p is "off" or "0" or "false" or "no")
|
||||
{
|
||||
patch["accs"] = new JObject { ["ban"] = false };
|
||||
err = null;
|
||||
}
|
||||
else
|
||||
err = "ban: on или off.";
|
||||
break;
|
||||
}
|
||||
|
||||
case "ids":
|
||||
if (string.IsNullOrEmpty(rest))
|
||||
err = "Укажи список uid через запятую или clear.";
|
||||
else if (rest.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
patch["accsRemove"] = new JArray("ids");
|
||||
err = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var ids = rest.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
if (ids.Count == 0)
|
||||
err = "Пустой список ids.";
|
||||
else
|
||||
{
|
||||
patch["accs"] = new JObject { ["ids"] = new JArray(ids) };
|
||||
err = null;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "param":
|
||||
{
|
||||
var eq = rest.IndexOf('=');
|
||||
if (eq < 1 || eq >= rest.Length - 1)
|
||||
err = "Формат: param ключ=значение";
|
||||
else
|
||||
{
|
||||
var k = rest.Substring(0, eq).Trim();
|
||||
var v = rest.Substring(eq + 1);
|
||||
if (string.IsNullOrEmpty(k))
|
||||
err = "Пустой ключ.";
|
||||
else
|
||||
{
|
||||
patch["accs"] = new JObject { ["params"] = new JObject { [k] = v } };
|
||||
err = null;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "clearparam":
|
||||
if (string.IsNullOrEmpty(rest))
|
||||
err = "Укажи ключ.";
|
||||
else
|
||||
{
|
||||
patch["paramsRemove"] = new JArray(rest.Trim());
|
||||
err = null;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "clear":
|
||||
{
|
||||
var keys = rest.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim().ToLowerInvariant())
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
if (keys.Count == 0)
|
||||
err = "Укажи поля: group, ispasswd, ban, ban_msg, comment, ids, params";
|
||||
else
|
||||
{
|
||||
patch["accsRemove"] = new JArray(keys);
|
||||
err = null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
err = "Неизвестная команда. Смотри <code>/setuser</code> без аргументов.";
|
||||
break;
|
||||
}
|
||||
|
||||
if (err != null)
|
||||
{
|
||||
await bot.SendMessage(chat.Id, "❌ " + err, parseMode: ParseMode.Html, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var (pok, detail) = await _api.PatchAdminUserAsync(patch, ct).ConfigureAwait(false);
|
||||
if (pok)
|
||||
await bot.SendMessage(chat.Id, $"✅ Сохранено для <code>{EscapeHtml(targetId)}</code>.", parseMode: ParseMode.Html, cancellationToken: ct).ConfigureAwait(false);
|
||||
else
|
||||
await bot.SendMessage(chat.Id,
|
||||
"❌ Ошибка API:\n" + TruncateForTelegram(StripJsonError(detail), 1200),
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task CmdImportAsync(ITelegramBotClient bot, Chat chat, string tgId, CancellationToken ct)
|
||||
{
|
||||
if (!await TryEnsureAdminMutationAccessAsync(bot, chat, tgId, ct).ConfigureAwait(false))
|
||||
return;
|
||||
|
||||
await bot.SendMessage(chat.Id, "⏳ Запускаю импорт…", cancellationToken: ct).ConfigureAwait(false);
|
||||
var (ok, detail) = await _api.ImportLegacyAsync(ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jo = JObject.Parse(detail);
|
||||
var msg =
|
||||
"✅ Импорт завершён.\n" +
|
||||
$"Пользователей: {jo.Value<int?>("importedUsers") ?? 0}, устройств: {jo.Value<int?>("importedDevices") ?? 0}, админов: {jo.Value<int?>("importedAdmins") ?? 0}, языков: {jo.Value<int?>("importedLangs") ?? 0}";
|
||||
await bot.SendMessage(chat.Id, msg, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await bot.SendMessage(chat.Id, "✅ Импорт завершён.\n" + TruncateForTelegram(detail, 3500), cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await bot.SendMessage(chat.Id, "❌ Ошибка импорта:\n" + TruncateForTelegram(detail, 3500), cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
async Task CmdCleanupAsync(ITelegramBotClient bot, Chat chat, string tgId, CancellationToken ct)
|
||||
{
|
||||
if (!await TryEnsureAdminMutationAccessAsync(bot, chat, tgId, ct).ConfigureAwait(false))
|
||||
return;
|
||||
|
||||
await bot.SendMessage(chat.Id, "⏳ Очистка неактивных устройств…", cancellationToken: ct).ConfigureAwait(false);
|
||||
var (ok, detail) = await _api.CleanupDevicesAsync(ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jo = JObject.Parse(detail);
|
||||
var removed = jo.Value<int?>("removed") ?? 0;
|
||||
await bot.SendMessage(chat.Id, $"✅ Готово. Удалено записей устройств: {removed}", cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await bot.SendMessage(chat.Id, "✅ Готово.\n" + TruncateForTelegram(detail, 3500), cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await bot.SendMessage(chat.Id, "❌ Ошибка очистки:\n" + TruncateForTelegram(detail, 3500), cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
using Telegram.Bot.Types;
|
||||
using TelegramAuthBot.Models;
|
||||
|
||||
namespace TelegramAuthBot.Services
|
||||
{
|
||||
sealed partial class TelegramAuthBotSession
|
||||
{
|
||||
async Task HandleCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct)
|
||||
{
|
||||
var data = cq.Data ?? "";
|
||||
if (data.StartsWith("unbind:", StringComparison.Ordinal))
|
||||
{
|
||||
await HandleUnbindDeviceCallbackAsync(bot, cq, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.StartsWith(CbReactivateDevice, StringComparison.Ordinal))
|
||||
{
|
||||
await HandleReactivateDeviceCallbackAsync(bot, cq, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.StartsWith(CbApprovePending, StringComparison.Ordinal)
|
||||
|| data.StartsWith(CbRejectPending, StringComparison.Ordinal))
|
||||
{
|
||||
await HandlePendingRegistrationCallbackAsync(bot, cq, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.StartsWith(CbUsersPage, StringComparison.Ordinal)
|
||||
|| data.StartsWith(CbDisableUser, StringComparison.Ordinal)
|
||||
|| data.StartsWith(CbEnableUser, StringComparison.Ordinal))
|
||||
{
|
||||
await HandleAdminUsersInlineAsync(bot, cq, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task HandleUnbindDeviceCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct)
|
||||
{
|
||||
var uid = cq.Data != null && cq.Data.Length > 7 ? cq.Data.Substring(7) : "";
|
||||
var tgId = cq.From.Id.ToString();
|
||||
var name = _displayName;
|
||||
|
||||
var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false);
|
||||
if (user == null || !user.found)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
await bot.EditMessageText(cq.Message!.Chat.Id, cq.Message.MessageId, $"Тебя нет в базе {name}.", cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var devicesResp = await _api.GetDevicesAsync(tgId, ct).ConfigureAwait(false);
|
||||
var devices = devicesResp?.devices ?? new List<DeviceDto>();
|
||||
var uids = devices.Select(d => d.uid).Where(u => !string.IsNullOrEmpty(u)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!uids.Contains(uid))
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, "Это устройство не принадлежит тебе.", cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var ok = await _api.UnbindDeviceAsync(tgId, uid, ct).ConfigureAwait(false);
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, $"Устройство {uid} отвязано.", cancellationToken: ct).ConfigureAwait(false);
|
||||
else
|
||||
await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, "Не удалось отвязать устройство.", cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task HandleReactivateDeviceCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct)
|
||||
{
|
||||
var prefixLen = CbReactivateDevice.Length;
|
||||
var uid = cq.Data != null && cq.Data.Length > prefixLen ? cq.Data.Substring(prefixLen) : "";
|
||||
var tgId = cq.From.Id.ToString();
|
||||
var name = _displayName;
|
||||
|
||||
var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false);
|
||||
if (user == null || !user.found)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
await bot.EditMessageText(cq.Message!.Chat.Id, cq.Message.MessageId, $"Тебя нет в базе {name}.", cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var devicesResp = await _api.GetDevicesAsync(tgId, ct).ConfigureAwait(false);
|
||||
var devices = devicesResp?.devices ?? new List<DeviceDto>();
|
||||
var uids = devices.Select(d => d.uid).Where(u => !string.IsNullOrEmpty(u)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!uids.Contains(uid))
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, "Это устройство не принадлежит тебе.", cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var (ok, detail) = await _api.ReactivateDeviceAsync(tgId, uid, ct).ConfigureAwait(false);
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, $"Устройство <code>{EscapeHtml(uid)}</code> снова активно. Открой приложение и при необходимости нажми «Проверить снова».", parseMode: ParseMode.Html, cancellationToken: ct).ConfigureAwait(false);
|
||||
else
|
||||
await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, "Не удалось включить устройство.\n" + TruncateForTelegram(StripJsonError(detail), 500), cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task HandlePendingRegistrationCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct)
|
||||
{
|
||||
if (!await TryEnsureAdminForCallbackAsync(bot, cq, ct).ConfigureAwait(false))
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var data = cq.Data ?? "";
|
||||
var approve = data.StartsWith(CbApprovePending, StringComparison.Ordinal);
|
||||
if (!approve && !data.StartsWith(CbRejectPending, StringComparison.Ordinal))
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var prefixLen = approve ? CbApprovePending.Length : CbRejectPending.Length;
|
||||
var rest = data.Length > prefixLen ? data.Substring(prefixLen) : "";
|
||||
var parts = rest.Split('|', 2);
|
||||
if (parts.Length != 2 || !int.TryParse(parts[0], out var returnPage) || returnPage < 0 || string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, "Некорректные данные кнопки.", showAlert: true, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var targetId = parts[1].Trim();
|
||||
var (ok, detail) = await _api.ResolveRegistrationPendingAsync(targetId, approve, ct).ConfigureAwait(false);
|
||||
if (!ok)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, TruncateForTelegram(StripJsonError(detail), 180), showAlert: true, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var msg = cq.Message;
|
||||
var actorTgId = cq.From.Id.ToString();
|
||||
if (msg != null && msg.Text != null && msg.Text.IndexOf("Пользователи TelegramAuth", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
var list = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false);
|
||||
if (list != null && list.ok)
|
||||
await SendOrEditAdminUsersPageAsync(bot, msg.Chat.Id, msg.MessageId, list, returnPage, actorTgId, ct).ConfigureAwait(false);
|
||||
}
|
||||
else if (msg != null)
|
||||
{
|
||||
var suffix = approve
|
||||
? "\n\n✅ <b>Подтверждено</b> — доступ включён."
|
||||
: "\n\n❌ <b>Отклонено</b> — запись пользователя удалена из базы.";
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(msg.Chat.Id, msg.MessageId, msg.Text + suffix, parseMode: ParseMode.Html, replyMarkup: null, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(cq.Id, approve ? "Доступ подтверждён." : "Запись удалена.", cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task HandleAdminUsersInlineAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct)
|
||||
{
|
||||
if (!await TryEnsureAdminForCallbackAsync(bot, cq, ct).ConfigureAwait(false))
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var msg = cq.Message;
|
||||
if (msg == null)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var chatId = msg.Chat.Id;
|
||||
var msgId = msg.MessageId;
|
||||
var actorTgId = cq.From.Id.ToString();
|
||||
var data = cq.Data ?? "";
|
||||
|
||||
if (data.StartsWith(CbUsersPage, StringComparison.Ordinal))
|
||||
{
|
||||
if (!int.TryParse(data.AsSpan(CbUsersPage.Length), out var page) || page < 0)
|
||||
page = 0;
|
||||
var list = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false);
|
||||
if (list == null || !list.ok)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, "Не удалось обновить список.", showAlert: true, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOrEditAdminUsersPageAsync(bot, chatId, msgId, list, page, actorTgId, ct).ConfigureAwait(false);
|
||||
await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.StartsWith(CbDisableUser, StringComparison.Ordinal) || data.StartsWith(CbEnableUser, StringComparison.Ordinal))
|
||||
{
|
||||
var disable = data.StartsWith(CbDisableUser, StringComparison.Ordinal);
|
||||
var rest = disable ? data.Substring(CbDisableUser.Length) : data.Substring(CbEnableUser.Length);
|
||||
var parts = rest.Split('|', 2);
|
||||
if (parts.Length != 2 || !int.TryParse(parts[0], out var returnPage) || returnPage < 0 || string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, "Некорректные данные кнопки.", showAlert: true, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var targetId = parts[1].Trim();
|
||||
if (disable && string.Equals(targetId, actorTgId, StringComparison.Ordinal))
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, "Нельзя отключить самого себя.", showAlert: true, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var (ok, detail) = await _api.SetUserDisabledAsync(targetId, disable, ct).ConfigureAwait(false);
|
||||
if (!ok)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(cq.Id, TruncateForTelegram(StripJsonError(detail), 180), showAlert: true, cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var fresh = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false);
|
||||
if (fresh != null && fresh.ok)
|
||||
await SendOrEditAdminUsersPageAsync(bot, chatId, msgId, fresh, returnPage, actorTgId, ct).ConfigureAwait(false);
|
||||
|
||||
await bot.AnswerCallbackQuery(cq.Id, disable ? "Доступ отключён." : "Доступ включён.", showAlert: false, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
namespace TelegramAuthBot.Services
|
||||
{
|
||||
sealed partial class TelegramAuthBotSession
|
||||
{
|
||||
static string TruncateForTelegram(string s, int maxLen)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s) || s.Length <= maxLen)
|
||||
return s ?? "";
|
||||
return s.Substring(0, maxLen) + "…";
|
||||
}
|
||||
|
||||
static string StripJsonError(string json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json))
|
||||
return "Ошибка API";
|
||||
try
|
||||
{
|
||||
var jo = JObject.Parse(json);
|
||||
var err = jo.Value<string>("error");
|
||||
var det = jo.Value<string>("detail");
|
||||
if (!string.IsNullOrEmpty(det))
|
||||
return det;
|
||||
return err ?? json;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
static string EscapeHtml(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s))
|
||||
return "";
|
||||
return s
|
||||
.Replace("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
using Telegram.Bot.Types;
|
||||
using TelegramAuthBot.Models;
|
||||
|
||||
namespace TelegramAuthBot.Services
|
||||
{
|
||||
sealed partial class TelegramAuthBotSession
|
||||
{
|
||||
async Task NotifyAdminsOfPendingProvisionAsync(ITelegramBotClient bot, string newUserTgId, string username, string deviceUid, CancellationToken ct)
|
||||
{
|
||||
var conf = ModInit.conf;
|
||||
if (!conf.notify_admins_on_pending_provision)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrEmpty(conf.mutations_api_secret?.Trim()))
|
||||
{
|
||||
TelegramAuthBotSerilog.Log.Warning(
|
||||
"Новый пользователь ожидает активации (TelegramId={TgId}), mutations_api_secret пуст — уведомления админам не отправлены.",
|
||||
newUserTgId);
|
||||
return;
|
||||
}
|
||||
|
||||
AdminUsersListResponseDto data;
|
||||
try
|
||||
{
|
||||
data = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TelegramAuthBotSerilog.Log.Warning(ex, "Не удалось загрузить список пользователей для уведомления о новом аккаунте");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.users == null || data.users.Count == 0)
|
||||
return;
|
||||
|
||||
var admins = data.users
|
||||
.Where(u => string.Equals(u.role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(u => !string.IsNullOrWhiteSpace(u.telegramId))
|
||||
.ToList();
|
||||
|
||||
if (admins.Count == 0)
|
||||
{
|
||||
TelegramAuthBotSerilog.Log.Warning(
|
||||
"Новый пользователь ожидает активации (TelegramId={TgId}), в базе нет пользователей с ролью admin.",
|
||||
newUserTgId);
|
||||
return;
|
||||
}
|
||||
|
||||
var at = string.IsNullOrEmpty(username) ? "—" : "@" + EscapeHtml(username);
|
||||
var text =
|
||||
"🔔 <b>Новый пользователь (ожидает решения)</b>\n\n" +
|
||||
$"<b>Telegram ID:</b> <code>{EscapeHtml(newUserTgId)}</code>\n" +
|
||||
$"<b>Username:</b> {at}\n" +
|
||||
$"<b>UID устройства:</b> <code>{EscapeHtml(deviceUid)}</code>\n\n" +
|
||||
"<b>Подтвердить</b> — включить доступ. <b>Отклонить</b> — удалить запись из базы.";
|
||||
|
||||
var cbApprove = CbApprovePending + "0|" + newUserTgId;
|
||||
var cbReject = CbRejectPending + "0|" + newUserTgId;
|
||||
InlineKeyboardMarkup notifyKb = null;
|
||||
if (Encoding.UTF8.GetByteCount(cbApprove) <= 64 && Encoding.UTF8.GetByteCount(cbReject) <= 64)
|
||||
{
|
||||
notifyKb = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Принять", cbApprove),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", cbReject)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var a in admins)
|
||||
{
|
||||
if (!long.TryParse(a.telegramId.Trim(), out var adminChatId))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(new ChatId(adminChatId), text, parseMode: ParseMode.Html, replyMarkup: notifyKb, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TelegramAuthBotSerilog.Log.Warning(ex, "Не удалось отправить уведомление администратору {AdminTgId}", a.telegramId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async Task<bool> TryBindAsync(ITelegramBotClient bot, ChatId chatId, string tgId, string username, string uid, bool fromStartDeepLink, CancellationToken ct)
|
||||
{
|
||||
var name = _displayName;
|
||||
var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false);
|
||||
if (user != null && user.found && !user.active)
|
||||
{
|
||||
string blocked;
|
||||
if (user.registrationPending)
|
||||
blocked = "Аккаунт ожидает подтверждения администратора. Когда доступ подтвердят, нажми «Проверить снова» в приложении.";
|
||||
else if (user.disabled)
|
||||
blocked = "Доступ отключён администратором. Если только что отправил UID, дождись включения и снова нажми «Проверить снова» в приложении.";
|
||||
else
|
||||
blocked = "Твой доступ истёк.";
|
||||
await bot.SendMessage(chatId, blocked, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
var bind = await _api.BindCompleteAsync(uid, tgId, username, ct).ConfigureAwait(false);
|
||||
if (bind.Ok)
|
||||
{
|
||||
if (bind.PendingAdminApproval)
|
||||
await NotifyAdminsOfPendingProvisionAsync(bot, tgId, username, uid, ct).ConfigureAwait(false);
|
||||
|
||||
string userText;
|
||||
if (bind.PendingAdminApproval)
|
||||
{
|
||||
userText =
|
||||
$"✅ <b>Запрос принят</b>\n\n<code>{EscapeHtml(uid)}</code>\n\n" +
|
||||
"Аккаунт создан и <b>ждёт подтверждения</b> администратора (уведомление с кнопками «Принять» / «Отклонить» или список <code>/users</code>).\n\n" +
|
||||
$"После подтверждения вернись в {EscapeHtml(name)} и нажми <b>«Проверить снова»</b>.";
|
||||
}
|
||||
else
|
||||
{
|
||||
var extra = fromStartDeepLink
|
||||
? ""
|
||||
: "\n\n💡 Подсказка: кнопкой <b>📱 Мои устройства</b> можно посмотреть и отвязать устройства.";
|
||||
userText =
|
||||
$"✅ <b>Устройство привязано</b>\n\n<code>{EscapeHtml(uid)}</code>\n\n" +
|
||||
$"Вернись в {EscapeHtml(name)} и нажми <b>«Проверить снова»</b>.{extra}";
|
||||
}
|
||||
|
||||
await bot.SendMessage(chatId, userText, parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fromStartDeepLink)
|
||||
return false;
|
||||
|
||||
if (user != null && user.found)
|
||||
await bot.SendMessage(chatId, "Не удалось привязать устройство.", replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
else
|
||||
await bot.SendMessage(chatId, $"Тебя нет в базе {EscapeHtml(name)}. Обратись к администратору.", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
async Task CmdMeAsync(ITelegramBotClient bot, ChatId chatId, string tgId, CancellationToken ct)
|
||||
{
|
||||
var name = _displayName;
|
||||
var data = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false);
|
||||
if (data == null || !data.found)
|
||||
{
|
||||
await bot.SendMessage(chatId, $"Тебя нет в базе авторизации {EscapeHtml(name)}.", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var maxDev = data.maxDevices == -1 ? "∞" : data.maxDevices.ToString();
|
||||
var expires = string.IsNullOrEmpty(data.expiresAt) ? "-" : data.expiresAt;
|
||||
var accessNote = data.registrationPending
|
||||
? " (ожидает подтверждения)"
|
||||
: data.disabled ? " (отключён администратором)" : "";
|
||||
var text =
|
||||
$"<b>Профиль {EscapeHtml(name)}</b>\n\n" +
|
||||
$"<b>Пользователь:</b> @{EscapeHtml(data.username ?? "-")}\n" +
|
||||
$"<b>Telegram ID:</b> <code>{EscapeHtml(data.telegramId ?? tgId)}</code>\n" +
|
||||
$"<b>Роль:</b> {EscapeHtml(data.role ?? "-")}\n" +
|
||||
$"<b>Язык:</b> {EscapeHtml(data.lang ?? "-")}\n" +
|
||||
$"<b>Активен:</b> {(data.active ? "да" : "нет")}{accessNote}\n" +
|
||||
$"<b>Срок доступа:</b> {EscapeHtml(expires)}\n" +
|
||||
$"<b>Устройств:</b> {data.deviceCount} / {maxDev}";
|
||||
await bot.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task CmdDevicesAsync(ITelegramBotClient bot, ChatId chatId, string tgId, CancellationToken ct)
|
||||
{
|
||||
var data = await _api.GetDevicesAsync(tgId, ct).ConfigureAwait(false);
|
||||
var devices = data?.devices ?? new List<DeviceDto>();
|
||||
if (devices.Count == 0)
|
||||
{
|
||||
await bot.SendMessage(chatId, "У тебя пока нет привязанных устройств.", cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = new List<string> { $"<b>Устройства @{EscapeHtml(data!.username ?? tgId)}:</b>" };
|
||||
var keyboard = new List<InlineKeyboardButton[]>();
|
||||
foreach (var d in devices)
|
||||
{
|
||||
var uid = d.uid ?? "";
|
||||
var devName = string.IsNullOrEmpty(d.name) ? "без имени" : d.name;
|
||||
var state = d.active ? "активно" : "отключено";
|
||||
lines.Add($"• <code>{EscapeHtml(uid)}</code> — {EscapeHtml(devName)} ({state})");
|
||||
if (!string.IsNullOrEmpty(uid) && d.active)
|
||||
keyboard.Add(new[] { InlineKeyboardButton.WithCallbackData($"Отвязать {devName}", "unbind:" + uid) });
|
||||
else if (!string.IsNullOrEmpty(uid) && !d.active)
|
||||
keyboard.Add(new[] { InlineKeyboardButton.WithCallbackData($"✅ Включить · {devName}", CbReactivateDevice + uid) });
|
||||
}
|
||||
|
||||
var markup = keyboard.Count > 0 ? new InlineKeyboardMarkup(keyboard) : null;
|
||||
await bot.SendMessage(chatId, string.Join("\n", lines), parseMode: ParseMode.Html, replyMarkup: markup, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task CmdDeviceNameAsync(ITelegramBotClient bot, ChatId chatId, string tgId, string text, CancellationToken ct)
|
||||
{
|
||||
var t = text.Trim();
|
||||
var cmdLen = "/devicename".Length;
|
||||
if (t.Length > cmdLen && t[cmdLen] == '@')
|
||||
{
|
||||
var at = t.IndexOf(' ', cmdLen);
|
||||
if (at < 0)
|
||||
{
|
||||
await bot.SendMessage(chatId,
|
||||
"<b>Переименование устройства</b>\n\n<code>/devicename <uid> <имя></code>\n\nUID из «Мои устройства». Чтобы сбросить имя: вместо имени отправь <code>-</code>.",
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: MainMenuKeyboard(),
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
cmdLen = at;
|
||||
}
|
||||
|
||||
var args = t.Length > cmdLen ? t.Substring(cmdLen).TrimStart() : "";
|
||||
if (string.IsNullOrEmpty(args))
|
||||
{
|
||||
await bot.SendMessage(chatId,
|
||||
"<b>Переименование устройства</b>\n\n<code>/devicename <uid> <имя></code>\n\nПример: <code>/devicename abc12xyz Телевизор в зале</code>\nСброс имени: <code>/devicename abc12xyz -</code>",
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: MainMenuKeyboard(),
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var firstSpace = args.IndexOf(' ');
|
||||
if (firstSpace < 0)
|
||||
{
|
||||
await bot.SendMessage(chatId, "Нужны UID и имя. Пример: <code>/devicename abc12xyz Телевизор</code>", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var uid = args.Substring(0, firstSpace).Trim();
|
||||
var newName = args.Substring(firstSpace + 1).Trim();
|
||||
if (string.IsNullOrEmpty(newName))
|
||||
{
|
||||
await bot.SendMessage(chatId, "Укажи новое имя после UID или <code>-</code> для сброса.", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!UidRe.IsMatch(uid))
|
||||
{
|
||||
await bot.SendMessage(chatId, "Некорректный UID.", replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var devicesResp = await _api.GetDevicesAsync(tgId, ct).ConfigureAwait(false);
|
||||
var devices = devicesResp?.devices ?? new List<DeviceDto>();
|
||||
var owned = devices.Any(d =>
|
||||
d != null
|
||||
&& d.active
|
||||
&& !string.IsNullOrEmpty(d.uid)
|
||||
&& string.Equals(d.uid, uid, StringComparison.OrdinalIgnoreCase));
|
||||
if (!owned)
|
||||
{
|
||||
await bot.SendMessage(chatId, "Активного устройства с таким UID у тебя нет. Список — в «Мои устройства».", replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
string apiName = string.Equals(newName, "-", StringComparison.Ordinal) ? null : newName;
|
||||
var (ok, detail) = await _api.SetDeviceDisplayNameAsync(uid, apiName, ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
{
|
||||
var shown = apiName == null ? "сброшено (без имени)" : $"«{EscapeHtml(apiName)}»";
|
||||
await bot.SendMessage(chatId, $"✅ Имя устройства <code>{EscapeHtml(uid)}</code> — {shown}.", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await bot.SendMessage(chatId,
|
||||
"❌ Не удалось сохранить имя. Если доступ истёк или устройство неактивно — привяжи снова.\n" + TruncateForTelegram(detail, 500),
|
||||
replyMarkup: MainMenuKeyboard(),
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
async Task CmdHelpAsync(ITelegramBotClient bot, ChatId chatId, CancellationToken ct)
|
||||
{
|
||||
var name = _displayName;
|
||||
var conf = ModInit.conf;
|
||||
var text =
|
||||
$"❓ <b>Помощь по входу в {EscapeHtml(name)}</b>\n\n" +
|
||||
"<b>Быстрый вход:</b>\n" +
|
||||
$"1. Открой {EscapeHtml(name)} и дойди до экрана авторизации\n" +
|
||||
"2. Скопируй UID устройства\n" +
|
||||
"3. Отправь UID мне сюда\n" +
|
||||
"4. Вернись в " + EscapeHtml(name) + " и нажми <b>«Проверить снова»</b>\n\n" +
|
||||
"<b>Кнопки:</b>\n" +
|
||||
"👤 Мой статус — профиль и срок доступа\n" +
|
||||
"📱 Мои устройства — список; отвязать активные, включить отключённые\n" +
|
||||
"<code>/devicename <uid> <имя></code> — имя в базе (для админки); <code>-</code> сбрасывает\n" +
|
||||
"❓ Помощь — эта подсказка\n\n" +
|
||||
"<b>Владелец:</b> числовой user id в <code>TelegramAuth.owner_telegram_ids</code> — при старте Lampac запись admin создаётся в базе. Остальные шлют UID; новых пользователей подтверждаешь в <code>/users</code> (Принять / Отклонить) или кнопками в уведомлении.\n\n" +
|
||||
"<b>Админ-команды:</b> <code>/users</code>, <code>/user</code> <id>, <code>/setuser</code> …, <code>/import</code>, <code>/cleanup</code> + одинаковый <code>mutations_api_secret</code>." +
|
||||
(conf.admin_chat_ids != null && conf.admin_chat_ids.Length > 0
|
||||
? "\n\n<code>admin_chat_ids</code> задан: команды из группы — только там; из лички — если твой id в <code>owner_telegram_ids</code> бота (как на сервере)."
|
||||
: "\n\nПустой <code>admin_chat_ids</code> — админ-команды из лички без доп. списков.");
|
||||
await bot.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
using Telegram.Bot.Types;
|
||||
using TelegramAuthBot.Models;
|
||||
|
||||
namespace TelegramAuthBot.Services
|
||||
{
|
||||
sealed partial class TelegramAuthBotSession
|
||||
{
|
||||
static readonly Regex UidRe = new(@"^[a-zA-Z0-9_-]{6,20}$", RegexOptions.Compiled);
|
||||
static readonly Regex StartCommandRe = new(@"^/start(?:@\w+)?(?:\s+(\S+))?\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
static readonly Regex AdminUserDetailRe = new(@"^/user(?:@\w+)?\s+(\d+)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
static readonly Regex AdminSetUserRe = new(@"^/setuser(?:@\w+)?\s+(\d+)\s+([a-zA-Z_]+)\s*(.*)$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
const string BtnStatus = "👤 Мой статус";
|
||||
const string BtnDevices = "📱 Мои устройства";
|
||||
const string BtnHelp = "❓ Помощь";
|
||||
|
||||
const int AdminUsersPageSize = 8;
|
||||
const string CbUsersPage = "ulp:";
|
||||
const string CbDisableUser = "d|";
|
||||
const string CbEnableUser = "e|";
|
||||
const string CbApprovePending = "ap|";
|
||||
const string CbRejectPending = "rp|";
|
||||
const string CbReactivateDevice = "react:";
|
||||
|
||||
readonly LampacTelegramAuthHttpClient _api;
|
||||
readonly string _displayName;
|
||||
int _firstUpdateLogged;
|
||||
|
||||
public TelegramAuthBotSession(LampacTelegramAuthHttpClient api, string displayName)
|
||||
{
|
||||
_api = api;
|
||||
_displayName = displayName;
|
||||
}
|
||||
|
||||
public async Task HandleUpdateAsync(ITelegramBotClient bot, Update update, CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _firstUpdateLogged, 1, 0) == 0)
|
||||
TelegramAuthBotSerilog.Log.Information("Первый апдейт {UpdateId}", update.Id);
|
||||
|
||||
if (update.CallbackQuery is { } cq)
|
||||
{
|
||||
await HandleCallbackAsync(bot, cq, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var msg = update.Message ?? update.EditedMessage;
|
||||
if (msg is not { } m)
|
||||
return;
|
||||
|
||||
var text = MessageTextOrCaption(m);
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return;
|
||||
|
||||
if (!TryResolveTelegramUserId(m, out var tgId))
|
||||
{
|
||||
TelegramAuthBotSerilog.Log.Warning("Не удалось определить telegram user id UpdateId={UpdateId} ChatType={ChatType}", update.Id, m.Chat.Type);
|
||||
await bot.SendMessage(m.Chat.Id,
|
||||
"Не могу определить твой Telegram ID в этом чате. Напиши боту в личные сообщения.",
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await HandleMessageAsync(bot, m, text, tgId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static string MessageTextOrCaption(Message msg)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(msg.Text))
|
||||
return msg.Text;
|
||||
if (!string.IsNullOrEmpty(msg.Caption))
|
||||
return msg.Caption;
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool TryResolveTelegramUserId(Message msg, out string tgId)
|
||||
{
|
||||
if (msg.From != null)
|
||||
{
|
||||
tgId = msg.From.Id.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.Chat.Type == ChatType.Private)
|
||||
{
|
||||
tgId = msg.Chat.Id.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
tgId = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
static ReplyKeyboardMarkup MainMenuKeyboard() =>
|
||||
new(new[]
|
||||
{
|
||||
new KeyboardButton[] { new(BtnStatus), new(BtnDevices) },
|
||||
new KeyboardButton[] { new(BtnHelp) }
|
||||
})
|
||||
{
|
||||
ResizeKeyboard = true
|
||||
};
|
||||
|
||||
async Task SendStartText(ITelegramBotClient bot, ChatId chatId, CancellationToken ct)
|
||||
{
|
||||
var name = _displayName;
|
||||
var text =
|
||||
$"✨ <b>Привет. Я бот авторизации {EscapeHtml(name)}.</b>\n\n" +
|
||||
$"С моей помощью можно быстро войти в {EscapeHtml(name)} через Telegram.\n\n" +
|
||||
"<b>Что я умею:</b>\n" +
|
||||
"• 🔗 привязать устройство по UID\n" +
|
||||
"• 👤 показать твой статус\n" +
|
||||
"• 📱 показать список устройств и переименовать (<code>/devicename</code>)\n" +
|
||||
"• 🗑️ отвязать устройство кнопкой\n\n" +
|
||||
"<b>Как войти:</b>\n" +
|
||||
$"1. Открой {EscapeHtml(name)}\n" +
|
||||
"2. Скопируй UID с экрана авторизации\n" +
|
||||
"3. Отправь его мне\n" +
|
||||
"4. Вернись в " + EscapeHtml(name) + " и нажми <b>«Проверить снова»</b>\n\n" +
|
||||
"Или просто используй кнопки ниже 👇";
|
||||
await bot.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task HandleMessageAsync(ITelegramBotClient bot, Message msg, string text, string tgId, CancellationToken ct)
|
||||
{
|
||||
var chatId = msg.Chat.Id;
|
||||
var username = msg.From?.Username ?? "";
|
||||
|
||||
if (text.StartsWith("/start", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var m = StartCommandRe.Match(text.Trim());
|
||||
var deepUid = m.Success && m.Groups[1].Success ? m.Groups[1].Value.Trim() : "";
|
||||
if (!string.IsNullOrEmpty(deepUid) && UidRe.IsMatch(deepUid))
|
||||
{
|
||||
var handled = await TryBindAsync(bot, chatId, tgId, username, deepUid, fromStartDeepLink: true, ct).ConfigureAwait(false);
|
||||
if (handled)
|
||||
return;
|
||||
}
|
||||
|
||||
await SendStartText(bot, chatId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCommand(text, "/help"))
|
||||
{
|
||||
await CmdHelpAsync(bot, chatId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCommand(text, "/me"))
|
||||
{
|
||||
await CmdMeAsync(bot, chatId, tgId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCommand(text, "/devices"))
|
||||
{
|
||||
await CmdDevicesAsync(bot, chatId, tgId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCommand(text, "/devicename"))
|
||||
{
|
||||
await CmdDeviceNameAsync(bot, chatId, tgId, text, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCommand(text, "/import"))
|
||||
{
|
||||
await CmdImportAsync(bot, msg.Chat, tgId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCommand(text, "/cleanup"))
|
||||
{
|
||||
await CmdCleanupAsync(bot, msg.Chat, tgId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCommand(text, "/users"))
|
||||
{
|
||||
await CmdUsersAsync(bot, msg.Chat, tgId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCommand(text, "/user"))
|
||||
{
|
||||
await CmdAdminUserDetailAsync(bot, msg.Chat, tgId, text, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsCommand(text, "/setuser"))
|
||||
{
|
||||
await CmdAdminSetUserAsync(bot, msg.Chat, tgId, text, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmed = text.Trim();
|
||||
if (trimmed == BtnStatus)
|
||||
{
|
||||
await CmdMeAsync(bot, chatId, tgId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed == BtnDevices)
|
||||
{
|
||||
await CmdDevicesAsync(bot, chatId, tgId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed == BtnHelp)
|
||||
{
|
||||
await CmdHelpAsync(bot, chatId, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith('/'))
|
||||
return;
|
||||
|
||||
if (UidRe.IsMatch(trimmed))
|
||||
{
|
||||
await TryBindAsync(bot, chatId, tgId, username, trimmed, fromStartDeepLink: false, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var name = _displayName;
|
||||
await bot.SendMessage(chatId,
|
||||
$"Я жду UID устройства из {EscapeHtml(name)} или используй кнопки ниже 👇",
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: MainMenuKeyboard(),
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static bool IsCommand(string text, string command)
|
||||
{
|
||||
var t = text.Trim();
|
||||
if (!t.StartsWith(command, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (t.Length == command.Length)
|
||||
return true;
|
||||
var c = t[command.Length];
|
||||
return c == ' ' || c == '@';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>library</OutputType>
|
||||
<IsPackable>true</IsPackable>
|
||||
<RootNamespace>TelegramAuthBot</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Telegram.Bot" Version="22.4.4" />
|
||||
<ProjectReference Include="..\..\..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyTelegramBotToModReferences" AfterTargets="ResolveReferences">
|
||||
<ItemGroup>
|
||||
<_TelegramBotRef Include="@(ReferencePath)" Condition="'%(Filename)' == 'Telegram.Bot'" />
|
||||
</ItemGroup>
|
||||
<MakeDir Directories="$(MSBuildProjectDirectory)\references" />
|
||||
<Copy SourceFiles="@(_TelegramBotRef)" DestinationFolder="$(MSBuildProjectDirectory)\references"
|
||||
SkipUnchangedFiles="true" Condition="@(_TelegramBotRef->Count()) != 0" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"TelegramAuthBot": {
|
||||
"enable": true,
|
||||
"bot_token": "PASTE_YOUR_TELEGRAM_BOT_TOKEN_HERE",
|
||||
"lampac_base_url": "http://127.0.0.1:9118",
|
||||
"request_timeout_sec": 10,
|
||||
"service_display_name": "Lampac NextGen Bot",
|
||||
"mutations_api_secret": "SAME_AS_TelegramAuth.mutations_api_secret",
|
||||
"admin_chat_ids": [],
|
||||
"owner_telegram_ids": [],
|
||||
"notify_admins_on_pending_provision": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,131 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Shared;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TelegramBot.Controllers
|
||||
{
|
||||
public class TgNotifyController : BaseController
|
||||
{
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("tg-notify.js")]
|
||||
public ActionResult TgNotifyPlugin()
|
||||
{
|
||||
// Ищем плагин в возможных расположениях модуля (NextGen: module/ или mods/)
|
||||
string file = null;
|
||||
foreach (var p in new[]
|
||||
{
|
||||
"module/Tg-notify.bot/tg-notify.js",
|
||||
"module/Community/Tg-notify.bot/tg-notify.js",
|
||||
"mods/TelegramBot/tg-notify.js",
|
||||
"module/TelegramBot/tg-notify.js",
|
||||
"plugins/tg-notify.js",
|
||||
"wwwroot/plugins/tg-notify.js"
|
||||
})
|
||||
{
|
||||
if (System.IO.File.Exists(p)) { file = p; break; }
|
||||
}
|
||||
|
||||
if (file == null)
|
||||
return Content(string.Empty, "application/javascript; charset=utf-8");
|
||||
|
||||
var js = System.IO.File.ReadAllText(file);
|
||||
js = js.Replace("{localhost}", host);
|
||||
return Content(js, "application/javascript; charset=utf-8");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("/api/tg/subscribe")]
|
||||
public async Task<ActionResult> Subscribe()
|
||||
{
|
||||
if (!ModInit.IsRunning)
|
||||
return Json(new { success = false, msg = "bot_not_running" });
|
||||
|
||||
var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString());
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Json(new { success = false, msg = "no_uid" });
|
||||
|
||||
using var reader = new StreamReader(HttpContext.Request.Body);
|
||||
var json = JObject.Parse(await reader.ReadToEndAsync());
|
||||
|
||||
var result = await ModInit.SubscribeApi(
|
||||
uid,
|
||||
json.Value<int>("tmdb_id"),
|
||||
json.Value<string>("title") ?? "",
|
||||
json.Value<string>("voice") ?? "",
|
||||
json.Value<int>("season"),
|
||||
json.Value<int>("episode"),
|
||||
json.Value<string>("mirage_orid") ?? "",
|
||||
json.Value<int>("mirage_voice_id"),
|
||||
json.Value<int>("voice_episode"),
|
||||
json.Value<string>("collaps_orid") ?? "",
|
||||
json.Value<string>("voice_source") ?? "",
|
||||
json.Value<int>("kp_id")
|
||||
);
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("/api/tg/unsubscribe")]
|
||||
public async Task<ActionResult> Unsubscribe()
|
||||
{
|
||||
if (!ModInit.IsRunning)
|
||||
return Json(new { success = false, msg = "bot_not_running" });
|
||||
|
||||
var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString());
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Json(new { success = false, msg = "no_uid" });
|
||||
|
||||
using var reader = new StreamReader(HttpContext.Request.Body);
|
||||
var json = JObject.Parse(await reader.ReadToEndAsync());
|
||||
|
||||
return Json(ModInit.UnsubscribeApi(uid, json.Value<int>("tmdb_id"), json.Value<string>("voice")));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("/api/tg/status")]
|
||||
public ActionResult Status(int tmdb_id)
|
||||
{
|
||||
var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString());
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Json(new { success = false, msg = "no_uid" });
|
||||
|
||||
return Json(ModInit.StatusApi(uid, tmdb_id));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("/api/tg/voices")]
|
||||
public async Task<ActionResult> Voices(string title, int year, int season, int tmdb_id)
|
||||
{
|
||||
return Json(await ModInit.GetVoicesApi(title ?? "", year, season, tmdb_id));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("/api/tg/link")]
|
||||
public async Task<ActionResult> GetLink()
|
||||
{
|
||||
var uid = requestInfo.user_uid;
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Json(new { success = false, msg = "no_uid" });
|
||||
|
||||
return Json(await ModInit.LinkApi(uid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список всех подписок пользователя с метаданными (для страницы подписок в Lampa)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Route("/api/tg/subscriptions")]
|
||||
public ActionResult Subscriptions()
|
||||
{
|
||||
var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString());
|
||||
if (string.IsNullOrEmpty(uid))
|
||||
return Json(new { success = false, msg = "no_uid" });
|
||||
|
||||
return Json(ModInit.SubscriptionsApi(uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
using Shared.Models.Module;
|
||||
|
||||
namespace TelegramBot.Models
|
||||
{
|
||||
public class TelegramBotConf : ModuleBaseConf
|
||||
{
|
||||
public bool enable { get; set; } = true;
|
||||
|
||||
public string bot_token { get; set; } = "YOUR_BOT_TOKEN";
|
||||
|
||||
public string tmdb_api_key { get; set; } = "";
|
||||
|
||||
public string trakt_client_id { get; set; } = "";
|
||||
|
||||
public string lampac_host { get; set; } = "http://127.0.0.1:9118";
|
||||
|
||||
public string lampac_token { get; set; } = "";
|
||||
|
||||
// Ключ kinopoiskapiunofficial.tech — резолвинг tmdb→kinopoisk_id по названию для источника VideoHub.
|
||||
// Пусто → kp_id резолвится только через мост Lampac /externalids (покрытие ~40%).
|
||||
public string kp_api_key { get; set; } = "";
|
||||
|
||||
public int check_interval_minutes { get; set; } = 60;
|
||||
|
||||
public string tmdb_lang { get; set; } = "ru-RU";
|
||||
|
||||
// Каталог для users.json / subscriptions.json. Пусто → database/tgnotify
|
||||
public string data_dir { get; set; } = "database/tgnotify";
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
# Tg-notify.bot
|
||||
|
||||
Фоновый **Telegram-бот уведомлений о новых сериях и озвучках** (long polling). Пользователь подписывается на сериал прямо из карточки Lampa, а бот сам отслеживает выход новых эпизодов и появление выбранной озвучки и шлёт уведомление в Telegram с обложкой, номером серии и описанием из TMDB.
|
||||
|
||||
Проект: **`Modules/Tg-notify.bot/`**. C#-namespace модуля — `TelegramBot`.
|
||||
|
||||
Бот объединяет три подсистемы:
|
||||
|
||||
1. **Трекер новых серий** — Trakt API (приоритет) с фоллбэком на TMDB.
|
||||
2. **Трекер озвучек** — параллельный опрос балансеров **Mirage**, **Collaps** и **VideoHub** (поиск по названию или kinopoisk_id, мультиозвучка, чистый JSON). Берётся максимальный эпизод из всех источников — кто первым выложил серию в нужной озвучке, от того и уведомление.
|
||||
3. **Клиентский плагин Lampa** (`tg-notify.js`) — вкладка «TG Подписки», перехват кнопки 🔔 в карточке, меню выбора озвучки.
|
||||
|
||||
---
|
||||
|
||||
## Включение
|
||||
|
||||
1. В шаблоне [`config/base.conf`](../../../config/base.conf) при необходимости проверьте, что **`TelegramBot`** не попал в **`BaseModule.SkipModules`** (если попал — уберите имя из списка, иначе модуль не загрузится).
|
||||
2. [`manifest.json`](manifest.json): выставьте **`"enable": true`** (в репозитории по умолчанию `false`).
|
||||
3. `init.conf`: добавьте секцию **`TelegramBot`**. Пример — [`init.merge.example.json`](init.merge.example.json).
|
||||
4. Перезапустите сервис: `systemctl restart lampac` (или имя вашего systemd-юнита).
|
||||
|
||||
При `enable: true` и пустом `bot_token` long polling **не стартует** — в лог пишется предупреждение.
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация (секция `init.conf`)
|
||||
|
||||
```json
|
||||
"TelegramBot": {
|
||||
"enable": true,
|
||||
"bot_token": "ТОКЕН_ОТ_BOTFATHER",
|
||||
"tmdb_api_key": "ВАШ_КЛЮЧ_TMDB",
|
||||
"trakt_client_id": "",
|
||||
"lampac_host": "http://127.0.0.1:9118",
|
||||
"lampac_token": "",
|
||||
"kp_api_key": "",
|
||||
"check_interval_minutes": 60,
|
||||
"tmdb_lang": "ru-RU",
|
||||
"data_dir": "database/tgnotify"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Назначение |
|
||||
|------|-----------|
|
||||
| `bot_token` | Токен бота от [@BotFather](https://t.me/BotFather). **Обязателен.** |
|
||||
| `tmdb_api_key` | Ключ TMDB v3 — обложки, описания эпизодов, определение актуального сезона. |
|
||||
| `trakt_client_id` | Client ID приложения Trakt. Если задан — основной источник новых серий; если пуст — используется только TMDB. |
|
||||
| `lampac_host` | Базовый URL самого Lampac для запросов к балансерам (`/lite/mirage`, `/lite/collaps`). Обычно локальный: `http://127.0.0.1:9118`. |
|
||||
| `lampac_token` | Токен **accsdb**. Обязателен, если у вас включена защита `accsdb` — без него балансеры вернут `{"accsdb":true,"msg":"Войдите в аккаунт"}`. |
|
||||
| `kp_api_key` | Ключ [kinopoiskapiunofficial.tech](https://kinopoiskapiunofficial.tech/) — резолвинг `tmdb_id → kinopoisk_id` по названию для источника **VideoHub**. Опционально: если пусто, `kinopoisk_id` берётся только из встроенного моста Lampac `/externalids` (покрытие ~40%); с ключом — фоллбэк через поиск по названию поднимает покрытие почти до полного. |
|
||||
| `check_interval_minutes` | Период фоновой проверки подписок. По умолчанию 60. Первый прогон — через 2 минуты после старта. |
|
||||
| `tmdb_lang` | Язык метаданных TMDB. По умолчанию `ru-RU` (с фоллбэком на `en-US` при пустом описании). |
|
||||
| `data_dir` | Каталог хранения `users.json` / `subscriptions.json`. По умолчанию `database/tgnotify`. |
|
||||
|
||||
---
|
||||
|
||||
## Зависимости
|
||||
|
||||
- **`Telegram.Bot` 22.4.4** — кладётся в [`references/`](references/) (для рантайм-компиляции Roslyn) и подтягивается через `PackageReference` при локальной сборке.
|
||||
- **TMDB API** — ключ v3 (`tmdb_api_key`).
|
||||
- **Trakt API** (опционально) — `trakt_client_id` для более точного и быстрого трекинга выхода серий.
|
||||
- Балансеры **Mirage**, **Collaps** и **VideoHub** (`/lite/cdnvideohub`) должны быть доступны по `lampac_host`.
|
||||
- **kinopoiskapiunofficial.tech** (опционально) — `kp_api_key`. Нужен только для VideoHub: резолвит `kinopoisk_id` по названию, когда встроенный мост Lampac его не знает.
|
||||
|
||||
---
|
||||
|
||||
## Структура модуля
|
||||
|
||||
```
|
||||
Tg-notify.bot/
|
||||
├── manifest.json # dynamic:true, references+tree
|
||||
├── Tg-notify.bot.csproj # только для локальной сборки; на сервере не используется
|
||||
├── init.merge.example.json # пример секции init.conf
|
||||
├── ModInit.cs # ядро: polling, трекинг серий, парсеры озвучек, HTTP API
|
||||
├── Models/
|
||||
│ └── TelegramBotConf.cs # конфиг (наследует ModuleBaseConf)
|
||||
├── Controllers/
|
||||
│ └── TgNotifyController.cs # HTTP API + раздача tg-notify.js
|
||||
├── references/
|
||||
│ └── Telegram.Bot.dll # 22.4.4
|
||||
└── tg-notify.js # клиентский плагин Lampa
|
||||
```
|
||||
|
||||
> На сервере модуль компилируется рантайм-движком **Roslyn** по `manifest.json` (`dynamic: true`) — `.csproj` нужен только для локальной разработки. Поля `references` и `tree` обязательны.
|
||||
|
||||
---
|
||||
|
||||
## Как работает
|
||||
|
||||
### Привязка аккаунта
|
||||
|
||||
UID пользователя определяется так же, как для закладок и тайм-кодов: плагин передаёт `token`, `account_email` и `uid` устройства, сервер берёт первое непустое (`RequestInfo.getuid`). Вошёл в аккаунт CUB — подписки общие для всех его устройств.
|
||||
|
||||
Из вкладки «TG Подписки» в Lampa пользователь получает deep-link на бота. Telegram пропускает в параметре только `A-Za-z0-9_-` (до 64 символов), поэтому UID вида email уходит как `link64_{base64url(uid)}`, остальные — как раньше, `link_{uid}`. Бот сохраняет связку `chat_id ↔ lampac_uid` (`users.json`), после чего подписки этого UID становятся «его». Привязка, сделанная раньше по `uid` устройства, продолжает работать: если под основным UID привязки нет, API ищет её по `uid` устройства.
|
||||
|
||||
### Подписка
|
||||
|
||||
В карточке сериала плагин перехватывает кнопку 🔔 и показывает меню озвучек (`/api/tg/voices` → Mirage + Collaps + VideoHub). Выбор отправляется в `/api/tg/subscribe` и сохраняется в `subscriptions.json`.
|
||||
|
||||
### Фоновый цикл `CheckAll`
|
||||
|
||||
Раз в `check_interval_minutes` (и вручную командой `/check`) по каждой подписке:
|
||||
|
||||
1. **Новые серии** — `CheckNewEpisodes` (Trakt → TMDB). Если у подписки не выбрана конкретная озвучка — уведомление шлётся о самом факте выхода серии.
|
||||
2. **Озвучки** (если озвучка выбрана) — **параллельно** (`Task.WhenAll`) опрашиваются Mirage, Collaps и VideoHub; берётся максимальный доступный эпизод; кто первый нашёл новую серию — от того уведомление. На каждую новую серию шлётся отдельное сообщение с инфой из TMDB.
|
||||
|
||||
> **VideoHub и kinopoisk_id.** VideoHub ищет контент только по `kinopoisk_id`, тогда как бот работает по `tmdb_id`. Резолвинг выполняется лениво и кешируется в самой подписке: сначала через встроенный мост Lampac `/externalids`, при неудаче — поиском по названию через `kp_api_key`. Имя озвучки в запросе URL-кодируется (кириллица иначе даёт HTTP 400). Если `kinopoisk_id` не нашёлся, VideoHub для этой подписки молча пропускается — Mirage и Collaps продолжают работать.
|
||||
|
||||
Формат уведомления:
|
||||
|
||||
```
|
||||
🎬 Название
|
||||
🎙 Озвучка
|
||||
📺 S05E08 — Название эпизода
|
||||
|
||||
Описание (до 300 символов)
|
||||
```
|
||||
|
||||
С обложкой кадра (`SendPhoto`), если у эпизода есть `still_path`.
|
||||
|
||||
---
|
||||
|
||||
## Команды бота
|
||||
|
||||
| Команда / кнопка | Действие |
|
||||
|------------------|----------|
|
||||
| `/start` | Приветствие; `/start link_{uid}` или `/start link64_{base64url(uid)}` — привязка аккаунта |
|
||||
| `/list` · «📋 Подписки» | Список активных подписок |
|
||||
| `/check` · «🔍 Проверить» | Принудительная проверка (в фоне) |
|
||||
| `/help` · «📖 Помощь» | Справка |
|
||||
| `/unlink` · «🔓 Отвязать» | Отвязать аккаунт |
|
||||
|
||||
---
|
||||
|
||||
## HTTP API (`TgNotifyController : BaseController`)
|
||||
|
||||
| Метод | Маршрут | Назначение |
|
||||
|-------|---------|-----------|
|
||||
| `GET` | `/tg-notify.js` | Раздаёт клиентский плагин с автозаменой `{localhost}` → реальный хост. `[AllowAnonymous]` |
|
||||
| `POST` | `/api/tg/subscribe` | Подписка на сериал/озвучку |
|
||||
| `POST` | `/api/tg/unsubscribe` | Отписка |
|
||||
| `GET` | `/api/tg/status?tmdb_id=` | Статус подписки текущего пользователя |
|
||||
| `GET` | `/api/tg/voices?title=&year=&season=&tmdb_id=` | Список доступных озвучек |
|
||||
| `GET` | `/api/tg/link` | Ссылка привязки бота |
|
||||
| `GET` | `/api/tg/subscriptions` | Все подписки пользователя с метаданными |
|
||||
|
||||
Все маршруты, кроме `/tg-notify.js` и `/api/tg/voices`, требуют `requestInfo.user_uid`.
|
||||
|
||||
---
|
||||
|
||||
## Клиентский плагин (`tg-notify.js`)
|
||||
|
||||
Подключается через `customPlugins` в `init.conf` либо раздаётся самим модулем по `/tg-notify.js`:
|
||||
|
||||
```json
|
||||
"LampaWeb": {
|
||||
"customPlugins": [
|
||||
{ "url": "{localhost}/tg-notify.js", "status": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Возможности: вкладка «TG Подписки» (сетка карточек с бейджем `S5 E4`), перехват кнопки 🔔 в полной карточке, меню выбора озвучки (`Lampa.Select.show`, метки `Mirage [—]` / `Collaps [C]` / `VideoHub [V]`), загрузка метаданных через `Lampa.TMDB` / `Lampa.Network.silent` с кешем. Хост подставляется через `{localhost}` — хардкода адресов нет.
|
||||
@@ -1,28 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>library</OutputType>
|
||||
<IsPackable>true</IsPackable>
|
||||
<RootNamespace>TelegramBot</RootNamespace>
|
||||
<AssemblyName>TelegramBot</AssemblyName>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Telegram.Bot" Version="22.4.4" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Кладёт Telegram.Bot.dll в references/ для рантайм-компиляции Roslyn на сервере -->
|
||||
<Target Name="CopyTelegramBotToModReferences" AfterTargets="ResolveReferences">
|
||||
<ItemGroup>
|
||||
<_TelegramBotRef Include="@(ReferencePath)" Condition="'%(Filename)' == 'Telegram.Bot'" />
|
||||
</ItemGroup>
|
||||
<MakeDir Directories="$(MSBuildProjectDirectory)\references" />
|
||||
<Copy SourceFiles="@(_TelegramBotRef)" DestinationFolder="$(MSBuildProjectDirectory)\references"
|
||||
SkipUnchangedFiles="true" Condition="@(_TelegramBotRef->Count()) != 0" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"TelegramBot": {
|
||||
"enable": true,
|
||||
"bot_token": "ВАШ_ТОКЕН_ОТ_BOTFATHER",
|
||||
"tmdb_api_key": "ВАШ_КЛЮЧ_TMDB",
|
||||
"trakt_client_id": "",
|
||||
"lampac_host": "http://127.0.0.1:9118",
|
||||
"lampac_token": "",
|
||||
"check_interval_minutes": 60,
|
||||
"tmdb_lang": "ru-RU",
|
||||
"data_dir": "database/tgnotify"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,407 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var lampac_host = '{localhost}';
|
||||
var network = new Lampa.Reguest();
|
||||
|
||||
// Пользователя выбирает сервер, как в account() остальных плагинов Lampac: token,
|
||||
// account_email и uid передаются отдельными параметрами, RequestInfo.getuid берёт первое
|
||||
// непустое. Иначе подписки живут под номером устройства, а закладки и тайм-коды того же
|
||||
// человека — под его аккаунтом.
|
||||
function apiUrl(path) {
|
||||
var url = lampac_host + path;
|
||||
var ids = {
|
||||
token: Lampa.Storage.get('token', ''),
|
||||
account_email: Lampa.Storage.get('account_email', ''),
|
||||
uid: Lampa.Storage.get('lampac_unic_id', '')
|
||||
};
|
||||
for (var name in ids) {
|
||||
if (ids[name]) url = Lampa.Utils.addUrlComponent(url, name + '=' + encodeURIComponent(ids[name]));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
|
||||
var TMDB_KEY = '';
|
||||
|
||||
// TMDB image/api с поддержкой прокси — используем встроенные методы Lampa
|
||||
function tmdbImage(url) {
|
||||
try { return Lampa.TMDB.image(url); } catch(e) {}
|
||||
return 'https://image.tmdb.org/' + url;
|
||||
}
|
||||
|
||||
function tmdbApi(path) {
|
||||
try { return Lampa.TMDB.api(path + '&api_key=' + (Lampa.TMDB.key ? Lampa.TMDB.key() : TMDB_KEY)); } catch(e) {}
|
||||
return 'https://api.themoviedb.org/3/' + path + '&api_key=' + TMDB_KEY;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Компонент подписок — построен по образцу
|
||||
// src/interaction/items/category/base.js
|
||||
// =============================================
|
||||
function TgSubscribesComponent(object) {
|
||||
var scroll = new Lampa.Scroll({ mask: true, over: true, step: 250, end_ratio: 2 });
|
||||
var items = [];
|
||||
var html = document.createElement('div');
|
||||
var body = $(document.createElement('div')); // jQuery объект, как в Lampa
|
||||
var last;
|
||||
var active = 0;
|
||||
|
||||
this.create = function () {
|
||||
var self = this;
|
||||
|
||||
// Как в base.js: сначала scroll.minus(), потом append body
|
||||
scroll.minus();
|
||||
scroll.append(body);
|
||||
html.appendChild(scroll.render(true));
|
||||
|
||||
// Layer.visible при скролле — для ленивой загрузки изображений
|
||||
scroll.onScroll = function () {
|
||||
Lampa.Layer.visible(scroll.render(true));
|
||||
};
|
||||
|
||||
self.activity.loader(true);
|
||||
|
||||
var net = new Lampa.Reguest();
|
||||
net.timeout(15000);
|
||||
|
||||
net['native'](apiUrl('/api/tg/subscriptions'), function (result) {
|
||||
if (!result.linked || !result.results || !result.results.length) {
|
||||
self.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
var subs = result.results;
|
||||
var lang = Lampa.Storage.field('tmdb_lang') || 'ru-RU';
|
||||
var total = subs.length;
|
||||
var done = 0;
|
||||
var tmdbData = {};
|
||||
|
||||
function buildCards() {
|
||||
// Классы сетки из items.js: mapping--grid + cols--6
|
||||
body.addClass('mapping--grid');
|
||||
body.addClass('cols--6');
|
||||
|
||||
var frag = document.createDocumentFragment();
|
||||
|
||||
subs.forEach(function (sub) {
|
||||
var t = tmdbData[sub.tmdb_id] || {};
|
||||
|
||||
var cardData = {
|
||||
id: sub.tmdb_id,
|
||||
tmdb_id: sub.tmdb_id,
|
||||
tmdb: sub.tmdb_id,
|
||||
name: t.name || sub.title,
|
||||
title: t.name || sub.title,
|
||||
original_name: t.original_name || sub.title,
|
||||
poster_path: t.poster_path || '',
|
||||
backdrop_path: t.backdrop_path || '',
|
||||
vote_average: t.vote_average || 0,
|
||||
first_air_date: t.first_air_date || '',
|
||||
number_of_seasons: t.number_of_seasons || sub.last_season,
|
||||
source: 'tmdb'
|
||||
};
|
||||
|
||||
if (t.poster_path)
|
||||
cardData.img = tmdbImage('t/p/w300' + t.poster_path);
|
||||
|
||||
var card = new Lampa.Card(cardData, { object: object, card_category: true });
|
||||
card.create();
|
||||
|
||||
var season = parseInt(sub.last_season) || 0;
|
||||
var episode = parseInt(sub.last_episode) || 0;
|
||||
var voice = (sub.voice || '').trim();
|
||||
|
||||
// Бейдж подписки — без pad() как в стандартном Subscribe модуле Lampa
|
||||
var badge = document.createElement('div');
|
||||
badge.className = 'card__subscribe';
|
||||
badge.innerHTML =
|
||||
'<div class="card__subscribe-status on"></div>' +
|
||||
'<div class="card__subscribe-position">S' + season + ' E' + episode + '</div>' +
|
||||
'<div class="card__subscribe-voice">' + (voice || 'Любая') + '</div>';
|
||||
var view = card.render(true).querySelector('.card__view');
|
||||
if (view) view.after(badge);
|
||||
|
||||
card.onFocus = function (target, data) {
|
||||
last = target;
|
||||
active = items.indexOf(card);
|
||||
scroll.update($(target), true);
|
||||
// Используем стандартную функцию Lampa — сама выбирает постер/backdrop по настройкам
|
||||
Lampa.Background.change(Lampa.Utils.cardImgBackground(data));
|
||||
};
|
||||
|
||||
card.onEnter = function (target, data) {
|
||||
Lampa.Activity.push({
|
||||
url: '', component: 'full',
|
||||
id: data.tmdb_id || data.id,
|
||||
method: 'tv', card: data, source: 'tmdb'
|
||||
});
|
||||
};
|
||||
|
||||
frag.appendChild(card.render(true));
|
||||
items.push(card);
|
||||
});
|
||||
|
||||
body.append(frag);
|
||||
|
||||
// Устанавливаем фон на первую карточку при открытии
|
||||
if (items.length > 0) {
|
||||
Lampa.Background.change(Lampa.Utils.cardImgBackground(items[0].data));
|
||||
}
|
||||
|
||||
// Layer.visible — запускает ленивую загрузку изображений в карточках
|
||||
Lampa.Layer.visible(scroll.render(true));
|
||||
|
||||
self.activity.loader(false);
|
||||
self.activity.toggle();
|
||||
}
|
||||
|
||||
// Параллельная загрузка TMDB через Lampa.Network (с кешем и прокси)
|
||||
subs.forEach(function (sub) {
|
||||
var url = tmdbApi('tv/' + sub.tmdb_id + '?language=' + lang);
|
||||
Lampa.Network.silent(url, function (t) {
|
||||
if (t && !t.status_code) tmdbData[sub.tmdb_id] = t;
|
||||
if (++done >= total) buildCards();
|
||||
},
|
||||
function () { if (++done >= total) buildCards(); },
|
||||
false, { cache: { life: 60 * 24 } }
|
||||
);
|
||||
});
|
||||
|
||||
}, function () { self.empty(); });
|
||||
};
|
||||
|
||||
this.empty = function () {
|
||||
var empty = new Lampa.Empty();
|
||||
html.appendChild(empty.render(true));
|
||||
this.start = empty.start.bind(empty);
|
||||
this.activity.loader(false);
|
||||
this.activity.toggle();
|
||||
};
|
||||
|
||||
// start() — точная копия base.js
|
||||
this.start = function () {
|
||||
var self = this;
|
||||
Lampa.Controller.add('content', {
|
||||
link: self,
|
||||
invisible: true,
|
||||
toggle: function () {
|
||||
scroll.restorePosition ? scroll.restorePosition() : null;
|
||||
Lampa.Controller.collectionSet(scroll.render(true));
|
||||
Lampa.Controller.collectionFocus(last || false, scroll.render(true));
|
||||
},
|
||||
left: function () { if (Navigator.canmove('left')) Navigator.move('left'); else Lampa.Controller.toggle('menu'); },
|
||||
right: function () { if (Navigator.canmove('right')) Navigator.move('right'); },
|
||||
up: function () { if (Navigator.canmove('up')) Navigator.move('up'); else Lampa.Controller.toggle('head'); },
|
||||
down: function () { if (Navigator.canmove('down')) Navigator.move('down'); },
|
||||
back: function () { Lampa.Activity.backward(); }
|
||||
});
|
||||
Lampa.Controller.toggle('content');
|
||||
};
|
||||
|
||||
this.pause = function () {};
|
||||
this.stop = function () {};
|
||||
this.render = function (js) { return js ? html : $(html); };
|
||||
this.destroy = function () {
|
||||
items.forEach(function (c) { try { c.destroy(); } catch(e) {} });
|
||||
scroll.destroy();
|
||||
html.remove();
|
||||
items = [];
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Меню
|
||||
// =============================================
|
||||
var MENU_ICON = '<svg><use xlink:href="#sprite-subscribes"></use></svg>';
|
||||
var MENU_TITLE = 'TG Подписки';
|
||||
var MENU_ACTION = 'tg_subscribes';
|
||||
|
||||
function openSubscribesPage() {
|
||||
Lampa.Activity.push({ url: '', title: MENU_TITLE, component: MENU_ACTION, page: 1 });
|
||||
}
|
||||
|
||||
function addMenu() {
|
||||
if ($('.menu__item[data-action="' + MENU_ACTION + '"]').length) return;
|
||||
Lampa.Menu.addButton(MENU_ICON, MENU_TITLE, openSubscribesPage).attr('data-action', MENU_ACTION);
|
||||
try {
|
||||
var sort = Lampa.Storage.get('menu_sort', '[]');
|
||||
if (typeof sort === 'string') sort = JSON.parse(sort);
|
||||
if (Array.isArray(sort) && sort.indexOf(MENU_TITLE) === -1) {
|
||||
sort.push(MENU_TITLE);
|
||||
Lampa.Storage.set('menu_sort', JSON.stringify(sort));
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function registerComponent() {
|
||||
Lampa.Component.add(MENU_ACTION, TgSubscribesComponent);
|
||||
setTimeout(addMenu, 500);
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Перехват кнопки 🔔 в карточке сериала
|
||||
// =============================================
|
||||
function initSubscribeOverride() {
|
||||
Lampa.Listener.follow('full', function (e) {
|
||||
if (e.type === 'complite')
|
||||
setTimeout(function () { overrideSubscribeButton(e.object); }, 300);
|
||||
});
|
||||
}
|
||||
|
||||
function overrideSubscribeButton(obj) {
|
||||
if (!obj || !obj.card || !obj.card.number_of_seasons) return;
|
||||
var card = obj.card;
|
||||
var button;
|
||||
try { button = obj.activity.render().find('.button--subscribe'); } catch(e) { return; }
|
||||
if (!button || !button.length) return;
|
||||
|
||||
button.removeClass('hide');
|
||||
button.off('hover:enter');
|
||||
|
||||
checkStatus(card.id, function (status) {
|
||||
if (status.subscribed) button.addClass('active').find('path').attr('fill', 'currentColor');
|
||||
button.on('hover:enter', function () {
|
||||
if (!status.linked) showLinkDialog();
|
||||
else showVoiceMenu(card, button, status);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showVoiceMenu(card, button, currentStatus) {
|
||||
var title = card.name || card.title || '';
|
||||
var year = card.first_air_date ? parseInt(card.first_air_date) : (card.year || 0);
|
||||
var tmdbId = card.id || 0;
|
||||
|
||||
Lampa.Loading.start(function () { network.clear(); Lampa.Loading.stop(); });
|
||||
network.clear();
|
||||
network.timeout(30000);
|
||||
network['native'](
|
||||
apiUrl('/api/tg/voices?title=' + encodeURIComponent(title) + '&year=' + year + '&season=0&tmdb_id=' + tmdbId),
|
||||
function (result) {
|
||||
Lampa.Loading.stop();
|
||||
var season = result.season || card.number_of_seasons || 1;
|
||||
var items = [];
|
||||
|
||||
if (currentStatus && currentStatus.subscribed && currentStatus.voices) {
|
||||
currentStatus.voices.forEach(function (v) {
|
||||
items.push({ title: '❌ Отписаться: ' + (v || 'Любая озвучка'), unsubscribe: true, voice: v });
|
||||
});
|
||||
if (items.length) items.push({ title: '', separator: true });
|
||||
}
|
||||
|
||||
items.push({ title: '🔔 Любая озвучка (оригинал)', subtitle: 'При выходе серии', voice: '', orid: '', voice_id: 0, collaps_orid: '', voice_source: '' });
|
||||
|
||||
if (result.success && result.voices && result.voices.length) {
|
||||
items.push({ title: 'Озвучки:', separator: true });
|
||||
result.voices.forEach(function (v) {
|
||||
var src = v.source || 'mirage';
|
||||
var badge = src === 'collaps' ? ' [C]' : src === 'rhs' ? ' [R]' : src === 'videohub' ? ' [V]' : '';
|
||||
items.push({ title: '🎙 ' + v.name + badge, voice: v.name, orid: result.orid || '', voice_id: v.id || 0, collaps_orid: result.collaps_orid || '', kp_id: result.kp_id || 0, voice_source: src });
|
||||
});
|
||||
}
|
||||
|
||||
Lampa.Select.show({
|
||||
title: 'TG Уведомления', items: items,
|
||||
onSelect: function (a) {
|
||||
Lampa.Controller.toggle('content');
|
||||
if (a.unsubscribe) doUnsubscribe(card, button, a.voice);
|
||||
else if (typeof a.voice !== 'undefined') doSubscribe(card, a.voice, season, button, a.orid, a.voice_id, a.collaps_orid, a.voice_source, a.kp_id);
|
||||
},
|
||||
onBack: function () { Lampa.Controller.toggle('content'); }
|
||||
});
|
||||
},
|
||||
function () {
|
||||
Lampa.Loading.stop();
|
||||
var items = [{ title: '🔔 Подписаться (без озвучки)', voice: '', orid: '', voice_id: 0 }];
|
||||
if (currentStatus && currentStatus.subscribed) items.unshift({ title: '❌ Отписаться', unsubscribe: true, voice: '' });
|
||||
Lampa.Select.show({
|
||||
title: 'TG Уведомления', items: items,
|
||||
onSelect: function (a) {
|
||||
Lampa.Controller.toggle('content');
|
||||
if (a.unsubscribe) doUnsubscribe(card, button, null);
|
||||
else doSubscribe(card, '', card.number_of_seasons || 1, button, '', 0);
|
||||
},
|
||||
onBack: function () { Lampa.Controller.toggle('content'); }
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function doSubscribe(card, voice, season, button, orid, voiceId, collapsOrid, voiceSource, kpId) {
|
||||
network.clear();
|
||||
network['native'](apiUrl('/api/tg/subscribe'), function (r) {
|
||||
if (r.success) {
|
||||
Lampa.Noty.show(voice ? '🔔 Подписка: ' + voice : '🔔 Подписка оформлена!');
|
||||
button.addClass('active').find('path').attr('fill', 'currentColor');
|
||||
} else if (r.msg === 'not_linked') {
|
||||
showLinkDialog();
|
||||
} else {
|
||||
Lampa.Noty.show('Ошибка: ' + (r.msg || ''));
|
||||
}
|
||||
}, function () {
|
||||
Lampa.Noty.show('Ошибка подписки');
|
||||
}, JSON.stringify({ tmdb_id: card.id, title: card.name || card.title || '', voice: voice, season: season, episode: 0, mirage_orid: orid || '', mirage_voice_id: voiceId || 0, voice_episode: 0, collaps_orid: collapsOrid || '', voice_source: voiceSource || '', kp_id: (card.kinopoisk_id || card.kp_id || kpId || 0) }),
|
||||
{ dataType: 'json', contentType: 'application/json' });
|
||||
}
|
||||
|
||||
function doUnsubscribe(card, button, voice) {
|
||||
network.clear();
|
||||
network['native'](apiUrl('/api/tg/unsubscribe'), function (r) {
|
||||
if (r.success) {
|
||||
Lampa.Noty.show('Подписка отменена');
|
||||
checkStatus(card.id, function (st) {
|
||||
if (!st.subscribed) button.removeClass('active').find('path').attr('fill', 'transparent');
|
||||
});
|
||||
}
|
||||
}, function () { Lampa.Noty.show('Ошибка'); },
|
||||
JSON.stringify({ tmdb_id: card.id, voice: voice }),
|
||||
{ dataType: 'json', contentType: 'application/json' });
|
||||
}
|
||||
|
||||
function checkStatus(tmdb_id, callback) {
|
||||
var n = new Lampa.Reguest();
|
||||
n.timeout(5000);
|
||||
n['native'](apiUrl('/api/tg/status?tmdb_id=' + tmdb_id),
|
||||
function (r) { callback(r || { success: false, linked: false, subscribed: false, voices: [] }); },
|
||||
function () { callback({ success: false, linked: false, subscribed: false, voices: [] }); }
|
||||
);
|
||||
}
|
||||
|
||||
function showLinkDialog() {
|
||||
var n = new Lampa.Reguest();
|
||||
n['native'](apiUrl('/api/tg/link'), function (r) {
|
||||
if (!r.success || !r.link) { Lampa.Noty.show('Бот не запущен'); return; }
|
||||
Lampa.Select.show({
|
||||
title: 'Привязка Telegram',
|
||||
items: [
|
||||
{ title: 'Открыть ссылку', subtitle: r.link, link: r.link },
|
||||
{ title: 'Скопировать ссылку', show_link: r.link }
|
||||
],
|
||||
onSelect: function (a) {
|
||||
Lampa.Controller.toggle('content');
|
||||
if (a.link) {
|
||||
if (typeof Android !== 'undefined' && Android.openBrowser) Android.openBrowser(a.link);
|
||||
else if (window.open) window.open(a.link, '_blank');
|
||||
}
|
||||
if (a.show_link) Lampa.Noty.show(a.show_link);
|
||||
},
|
||||
onBack: function () { Lampa.Controller.toggle('content'); }
|
||||
});
|
||||
}, function () { Lampa.Noty.show('Модуль TelegramBot недоступен'); });
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Init
|
||||
// =============================================
|
||||
function init() {
|
||||
registerComponent();
|
||||
initSubscribeOverride();
|
||||
console.log('[TG-Notify] Plugin loaded v17');
|
||||
}
|
||||
|
||||
if (window.appready) init();
|
||||
else Lampa.Listener.follow('app', function (e) { if (e.type === 'ready') init(); });
|
||||
|
||||
})();
|
||||
@@ -1,162 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared;
|
||||
using Shared.Attributes;
|
||||
using Shared.Services;
|
||||
using Shared.Services.Utilities;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
|
||||
namespace Tracks;
|
||||
|
||||
public class TracksController : BaseController
|
||||
{
|
||||
[HttpGet, AllowAnonymous]
|
||||
[Staticache(cacheMinutes: 10, always: true, setHeadersNoCache: true)]
|
||||
[Route("tracks.js")]
|
||||
[Route("tracks/js/{token}")]
|
||||
public ActionResult Tracks(string token)
|
||||
{
|
||||
string plugin = FileCache.ReadAllText($"{ModInit.modpath}/plugin.js", "tracks.js")
|
||||
.Replace("{localhost}", host)
|
||||
.Replace("{token}", HttpUtility.UrlEncode(token));
|
||||
|
||||
return ContentTo(plugin, "application/javascript; charset=utf-8");
|
||||
}
|
||||
|
||||
|
||||
[HttpGet]
|
||||
[Route("ffprobe")]
|
||||
async public Task<ActionResult> Ffprobe(string media, bool showerror)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(media) || !media.StartsWith("http") || media.Contains("/transcoding/"))
|
||||
return ContentTo("{}");
|
||||
|
||||
string magnethash = null;
|
||||
|
||||
if (media.Contains("/dlna/stream"))
|
||||
{
|
||||
string path = Regex.Match(media, "\\?path=([^&]+)").Groups[1].Value;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return ContentTo(showerror ? "path" : "{}");
|
||||
|
||||
magnethash = HttpUtility.UrlDecode(path);
|
||||
}
|
||||
else if (media.Contains("/stream/") || media.Contains("/lite/pidtor/"))
|
||||
{
|
||||
media = Regex.Replace(media, "[^a-z0-9_:\\-\\/\\.\\=\\?\\&\\%\\@]+", "", RegexOptions.IgnoreCase);
|
||||
|
||||
if (media.Contains("/stream/"))
|
||||
{
|
||||
if (ModInit.conf.tsuri != null)
|
||||
media = Regex.Replace(media, "^https?://[^/]+", ModInit.conf.tsuri, RegexOptions.IgnoreCase);
|
||||
|
||||
// TorrServer returns an empty body for preload/stat/m3u; ffprobe needs the play stream
|
||||
int q = media.IndexOf('?');
|
||||
if (q > 0)
|
||||
{
|
||||
var args = media[(q + 1)..].Split('&', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(a => !Regex.IsMatch(a, "^(preload|stat|m3u|play)(=|$)", RegexOptions.IgnoreCase));
|
||||
|
||||
media = media[..q] + "?" + string.Join("&", args.Append("play"));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (media.Contains("/proxy/") && media.Contains(".mkv"))
|
||||
{
|
||||
string hash = Regex.Match(media, "/proxy/([^\n\r]+\\.mkv)").Groups[1].Value;
|
||||
media = ProxyLink.Decrypt(hash.AsSpan(), null)?.uri;
|
||||
if (string.IsNullOrWhiteSpace(media))
|
||||
return ContentTo(showerror ? "media" : "{}");
|
||||
}
|
||||
|
||||
string argumentList = string.Empty;
|
||||
|
||||
string memKey = $"tracks:ffprobe:{media}";
|
||||
if (!hybridCache.TryGetValue(memKey, out string outPut))
|
||||
{
|
||||
#region getFolder
|
||||
static string getFolder(string magnethash)
|
||||
{
|
||||
return $"database/tracks/{magnethash}";
|
||||
}
|
||||
#endregion
|
||||
|
||||
if (media.Contains("/stream/"))
|
||||
{
|
||||
magnethash = Regex.Match(media, "link=([a-z0-9]+)").Groups[1].Value;
|
||||
string index = Regex.Match(media, @"index=([0-9]+)", RegexOptions.IgnoreCase).Groups[1].Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(magnethash))
|
||||
magnethash = $"{magnethash}_{index}";
|
||||
}
|
||||
else if (media.Contains("/lite/pidtor/"))
|
||||
{
|
||||
magnethash = Regex.Match(media, "/lite/pidtor/s([a-z0-9]+)").Groups[1].Value;
|
||||
string index = Regex.Match(media, @"tsid=([0-9]+)", RegexOptions.IgnoreCase).Groups[1].Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(magnethash))
|
||||
magnethash = $"{magnethash}_{index}";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(magnethash))
|
||||
magnethash = media;
|
||||
|
||||
// A cache key must never remain a user-controlled filesystem path.
|
||||
magnethash = CrypTo.md5(magnethash);
|
||||
|
||||
if (System.IO.File.Exists(getFolder(magnethash)))
|
||||
outPut = await BrotliTo.DecompressAsync(getFolder(magnethash));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outPut))
|
||||
{
|
||||
if (!Uri.TryCreate(media, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
return ContentTo(showerror ? "uri" : "{}");
|
||||
|
||||
var process = new System.Diagnostics.Process();
|
||||
process.StartInfo.UseShellExecute = false;
|
||||
process.StartInfo.RedirectStandardOutput = true;
|
||||
process.StartInfo.StandardOutputEncoding = Encoding.UTF8;
|
||||
process.StartInfo.FileName = CoreInit.Win32NT ? "data/ffprobe.exe" : System.IO.File.Exists("data/ffprobe") ? "data/ffprobe" : "ffprobe";
|
||||
|
||||
process.StartInfo.ArgumentList.Add("-v");
|
||||
process.StartInfo.ArgumentList.Add("quiet");
|
||||
process.StartInfo.ArgumentList.Add("-print_format");
|
||||
process.StartInfo.ArgumentList.Add("json");
|
||||
process.StartInfo.ArgumentList.Add("-show_format");
|
||||
process.StartInfo.ArgumentList.Add("-show_streams");
|
||||
process.StartInfo.ArgumentList.Add(AccsDbInvk.Args(uri.AbsoluteUri, HttpContext));
|
||||
|
||||
argumentList = process.StartInfo.FileName + " " + string.Join(" ", process.StartInfo.ArgumentList);
|
||||
|
||||
process.Start();
|
||||
|
||||
outPut = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (outPut == null)
|
||||
outPut = string.Empty;
|
||||
|
||||
if (Regex.Replace(outPut, "[\n\r\t ]+", "") == "{}")
|
||||
outPut = string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outPut) && !string.IsNullOrWhiteSpace(magnethash))
|
||||
{
|
||||
await BrotliTo.CompressAsync(getFolder(magnethash), outPut);
|
||||
}
|
||||
else
|
||||
{
|
||||
// заглушка
|
||||
hybridCache.Set(memKey, outPut, DateTime.Now.AddMinutes(5), inmemory: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ContentTo(string.IsNullOrEmpty(outPut) ? (showerror ? argumentList : "{}") : outPut);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using Shared;
|
||||
using Shared.Services;
|
||||
using Shared.Models.AppConf;
|
||||
using Shared.Models.Events;
|
||||
using Shared.Models.Module;
|
||||
using Shared.Models.Module.Interfaces;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace Tracks;
|
||||
|
||||
public class ModInit : IModuleLoaded
|
||||
{
|
||||
public static string modpath;
|
||||
public static ModuleConf conf;
|
||||
|
||||
public void Loaded(InitspaceModel initspace)
|
||||
{
|
||||
modpath = initspace.path;
|
||||
|
||||
updateConf();
|
||||
EventListener.UpdateInitFile += updateConf;
|
||||
|
||||
foreach (var m in conf.limit_map)
|
||||
CoreInit.conf.WAF.limit_map.Insert(0, m);
|
||||
|
||||
Directory.CreateDirectory("database/tracks");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
EventListener.UpdateInitFile -= updateConf;
|
||||
}
|
||||
|
||||
void updateConf()
|
||||
{
|
||||
conf = ModuleInvoke.Init("Tracks", new ModuleConf()
|
||||
{
|
||||
tsuri = null,
|
||||
limit_map = new List<WafLimitRootMap>()
|
||||
{
|
||||
new("^/ffprobe", new WafLimitMap { limit = 10, second = 1 })
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using Shared.Models.Module;
|
||||
|
||||
namespace Tracks;
|
||||
|
||||
public class ModuleConf : ModuleBaseConf
|
||||
{
|
||||
public string tsuri { get; set; }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
# Tracks
|
||||
|
||||
Модуль **Tracks**: загружает **`ModuleConf`** и регистрирует лимиты WAF для тяжёлых endpoint’ов (например **`ffprobe`**). Путь модуля доступен как **`Tracks.modpath`**.
|
||||
|
||||
## Интерфейс
|
||||
|
||||
**`IModuleLoaded`** — без **`IModuleOnline`**.
|
||||
|
||||
## Поведение (`Loaded`)
|
||||
|
||||
- **`modpath = initspace.path`**;
|
||||
- **`conf.limit_map`** вставляется в начало **`CoreInit.conf.WAF.limit_map`**;
|
||||
- создаётся каталог **`database/tracks`**.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Секция в `init.conf`: **`Tracks`** (`ModuleConf`).
|
||||
|
||||
По умолчанию в коде: **`tsuri = null`**, правило **`^/ffprobe`** — **`limit = 10`** запросов за **`1`** секунду.
|
||||
|
||||
## HTTP
|
||||
|
||||
| Маршрут | Назначение |
|
||||
|---------|------------|
|
||||
| **`tracks.js`**, **`tracks/js/{token}`** | Клиентский скрипт / токенизированная версия. |
|
||||
| **`ffprobe`** | Вызов ffprobe (ограничен WAF). |
|
||||
|
||||
## Файлы
|
||||
|
||||
**`ModInit.cs`**, **`Controller.cs`**.
|
||||
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>library</OutputType>
|
||||
<IsPackable>true</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="plugin.js">
|
||||
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,416 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var connect_host = '{localhost}';
|
||||
var list_opened = false;
|
||||
|
||||
var unic_id = Lampa.Storage.get('lampac_unic_id', '');
|
||||
if (!unic_id) {
|
||||
unic_id = Lampa.Utils.uid(8).toLowerCase();
|
||||
Lampa.Storage.set('lampac_unic_id', unic_id);
|
||||
}
|
||||
|
||||
function reguest(params, callback) {
|
||||
if (params.ffprobe) {
|
||||
setTimeout(function () {
|
||||
callback({
|
||||
streams: params.ffprobe
|
||||
});
|
||||
}, 200);
|
||||
} else {
|
||||
var net = new Lampa.Reguest();
|
||||
net.timeout(1000 * 15);
|
||||
var url = connect_host + '/ffprobe?media=' + encodeURIComponent(params.url);
|
||||
|
||||
if (url.indexOf('account_email=') == -1) {
|
||||
var email = Lampa.Storage.get('account_email');
|
||||
if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email));
|
||||
}
|
||||
|
||||
if (url.indexOf('uid=') == -1) {
|
||||
var uid = Lampa.Storage.get('lampac_unic_id', '');
|
||||
if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid));
|
||||
}
|
||||
|
||||
if (url.indexOf('token=') == -1) {
|
||||
var token = '{token}';
|
||||
if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}');
|
||||
}
|
||||
|
||||
net["native"](url, function (str) {
|
||||
var json = {};
|
||||
|
||||
try {
|
||||
json = JSON.parse(str);
|
||||
} catch (e) { }
|
||||
|
||||
callback(json.streams && json.streams.length ? json : null);
|
||||
}, function () {
|
||||
callback(null);
|
||||
}, false, {
|
||||
dataType: 'text'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeTracks(data) {
|
||||
var inited = false;
|
||||
var inited_parse = false;
|
||||
var webos_replace = {};
|
||||
|
||||
function log() {
|
||||
console.log.apply(console.log, arguments);
|
||||
}
|
||||
|
||||
function getTracks() {
|
||||
var video = Lampa.PlayerVideo.video();
|
||||
return video.audioTracks || [];
|
||||
}
|
||||
|
||||
function getSubs() {
|
||||
var video = Lampa.PlayerVideo.video();
|
||||
return video.textTracks || [];
|
||||
}
|
||||
|
||||
log('Tracks', 'start');
|
||||
|
||||
function setTracks() {
|
||||
if (inited_parse) {
|
||||
var new_tracks = [];
|
||||
var video_tracks = getTracks();
|
||||
var parse_tracks = inited_parse.streams.filter(function (a) {
|
||||
return a.codec_type == 'audio';
|
||||
});
|
||||
var minus = 1;
|
||||
log('Tracks', 'set tracks:', video_tracks.length);
|
||||
if (parse_tracks.length !== video_tracks.length) parse_tracks = parse_tracks.filter(function (a) {
|
||||
return a.codec_name !== 'dts';
|
||||
});
|
||||
parse_tracks = parse_tracks.filter(function (a) {
|
||||
return a.tags;
|
||||
});
|
||||
log('Tracks', 'filtred tracks:', parse_tracks.length);
|
||||
parse_tracks.forEach(function (track) {
|
||||
var orig = video_tracks[track.index - minus];
|
||||
var elem = {
|
||||
index: track.index - minus,
|
||||
language: track.tags.language,
|
||||
label: track.tags.title || track.tags.handler_name,
|
||||
ghost: orig ? false : true,
|
||||
selected: orig ? orig.selected == true || orig.enabled == true : false
|
||||
};
|
||||
console.log('Tracks', 'tracks original', orig);
|
||||
Object.defineProperty(elem, "enabled", {
|
||||
set: function set(v) {
|
||||
if (v) {
|
||||
var aud = getTracks();
|
||||
var trk = aud[elem.index];
|
||||
|
||||
for (var i = 0; i < aud.length; i++) {
|
||||
aud[i].enabled = false;
|
||||
aud[i].selected = false;
|
||||
}
|
||||
|
||||
if (trk) {
|
||||
trk.enabled = true;
|
||||
trk.selected = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
get: function get() { }
|
||||
});
|
||||
new_tracks.push(elem);
|
||||
});
|
||||
if (parse_tracks.length) Lampa.PlayerPanel.setTracks(new_tracks);
|
||||
}
|
||||
}
|
||||
|
||||
function setSubs() {
|
||||
if (inited_parse) {
|
||||
var new_subs = [];
|
||||
var video_subs = getSubs();
|
||||
var parse_subs = inited_parse.streams.filter(function (a) {
|
||||
return a.codec_type == 'subtitle';
|
||||
});
|
||||
var minus = inited_parse.streams.filter(function (a) {
|
||||
return a.codec_type == 'audio';
|
||||
}).length + 1;
|
||||
log('Tracks', 'set subs:', video_subs.length);
|
||||
parse_subs = parse_subs.filter(function (a) {
|
||||
return a.tags;
|
||||
});
|
||||
log('Tracks', 'filtred subs:', parse_subs.length);
|
||||
parse_subs.forEach(function (track) {
|
||||
var orig = video_subs[track.index - minus];
|
||||
var elem = {
|
||||
index: track.index - minus,
|
||||
language: track.tags.language,
|
||||
label: track.tags.title || track.tags.handler_name,
|
||||
ghost: video_subs[track.index - minus] ? false : true,
|
||||
selected: orig ? orig.selected == true || orig.mode == 'showing' : false
|
||||
};
|
||||
console.log('Tracks', 'subs original', orig);
|
||||
Object.defineProperty(elem, "mode", {
|
||||
set: function set(v) {
|
||||
if (v) {
|
||||
var txt = getSubs();
|
||||
var sub = txt[elem.index];
|
||||
|
||||
for (var i = 0; i < txt.length; i++) {
|
||||
txt[i].mode = 'disabled';
|
||||
txt[i].selected = false;
|
||||
}
|
||||
|
||||
if (sub) {
|
||||
sub.mode = 'showing';
|
||||
sub.selected = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
get: function get() { }
|
||||
});
|
||||
new_subs.push(elem);
|
||||
});
|
||||
if (parse_subs.length) Lampa.PlayerPanel.setSubs(new_subs);
|
||||
}
|
||||
}
|
||||
|
||||
function listenTracks() {
|
||||
log('Tracks', 'tracks video event');
|
||||
setTracks();
|
||||
Lampa.PlayerVideo.listener.remove('tracks', listenTracks);
|
||||
}
|
||||
|
||||
function listenSubs() {
|
||||
log('Tracks', 'subs video event');
|
||||
setSubs();
|
||||
Lampa.PlayerVideo.listener.remove('subs', listenSubs);
|
||||
}
|
||||
|
||||
function canPlay() {
|
||||
log('Tracks', 'canplay video event');
|
||||
if (webos_replace.tracks) setWebosTracks(webos_replace.tracks); else setTracks();
|
||||
if (webos_replace.subs) setWebosSubs(webos_replace.subs); else setSubs();
|
||||
Lampa.PlayerVideo.listener.remove('canplay', canPlay);
|
||||
}
|
||||
|
||||
function setWebosTracks(video_tracks) {
|
||||
if (inited_parse) {
|
||||
var parse_tracks = inited_parse.streams.filter(function (a) {
|
||||
return a.codec_type == 'audio';
|
||||
});
|
||||
log('Tracks', 'webos set tracks:', video_tracks.length);
|
||||
|
||||
if (parse_tracks.length !== video_tracks.length) {
|
||||
parse_tracks = parse_tracks.filter(function (a) {
|
||||
return a.codec_name !== 'truehd';
|
||||
});
|
||||
|
||||
if (parse_tracks.length !== video_tracks.length) {
|
||||
parse_tracks = parse_tracks.filter(function (a) {
|
||||
return a.codec_name !== 'dts';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
parse_tracks = parse_tracks.filter(function (a) {
|
||||
return a.tags;
|
||||
});
|
||||
log('Tracks', 'webos tracks', video_tracks);
|
||||
parse_tracks.forEach(function (track, i) {
|
||||
if (video_tracks[i]) {
|
||||
video_tracks[i].language = track.tags.language;
|
||||
video_tracks[i].label = track.tags.title || track.tags.handler_name;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setWebosSubs(video_subs) {
|
||||
if (inited_parse) {
|
||||
var parse_subs = inited_parse.streams.filter(function (a) {
|
||||
return a.codec_type == 'subtitle';
|
||||
});
|
||||
log('Tracks', 'webos set subs:', video_subs.length);
|
||||
if (parse_subs.length !== video_subs.length - 1) parse_subs = parse_subs.filter(function (a) {
|
||||
return a.codec_name !== 'hdmv_pgs_subtitle';
|
||||
});
|
||||
parse_subs = parse_subs.filter(function (a) {
|
||||
return a.tags;
|
||||
});
|
||||
parse_subs.forEach(function (track, a) {
|
||||
var i = a + 1;
|
||||
|
||||
if (video_subs[i]) {
|
||||
video_subs[i].language = track.tags.language;
|
||||
video_subs[i].label = track.tags.title || track.tags.handler_name;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function listenWebosSubs(_data) {
|
||||
log('Tracks', 'webos subs event');
|
||||
webos_replace.subs = _data.subs;
|
||||
if (inited_parse) setWebosSubs(_data.subs);
|
||||
}
|
||||
|
||||
function listenWebosTracks(_data) {
|
||||
log('Tracks', 'webos tracks event');
|
||||
webos_replace.tracks = _data.tracks;
|
||||
if (inited_parse) setWebosTracks(_data.tracks);
|
||||
}
|
||||
|
||||
function listenStart() {
|
||||
inited = true;
|
||||
reguest(data, function (result) {
|
||||
if (!result) return log('Tracks', 'no metadata');
|
||||
log('Tracks', 'parsed', inited_parse);
|
||||
inited_parse = result;
|
||||
|
||||
if (inited) {
|
||||
if (webos_replace.subs) setWebosSubs(webos_replace.subs); else setSubs();
|
||||
if (webos_replace.tracks) setWebosTracks(webos_replace.tracks); else setTracks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function listenDestroy() {
|
||||
inited = false;
|
||||
Lampa.Player.listener.remove('destroy', listenDestroy);
|
||||
Lampa.PlayerVideo.listener.remove('tracks', listenTracks);
|
||||
Lampa.PlayerVideo.listener.remove('subs', listenSubs);
|
||||
Lampa.PlayerVideo.listener.remove('webos_subs', listenWebosSubs);
|
||||
Lampa.PlayerVideo.listener.remove('webos_tracks', listenWebosTracks);
|
||||
Lampa.PlayerVideo.listener.remove('canplay', canPlay);
|
||||
log('Tracks', 'end');
|
||||
}
|
||||
|
||||
Lampa.Player.listener.follow('destroy', listenDestroy);
|
||||
Lampa.PlayerVideo.listener.follow('tracks', listenTracks);
|
||||
Lampa.PlayerVideo.listener.follow('subs', listenSubs);
|
||||
Lampa.PlayerVideo.listener.follow('webos_subs', listenWebosSubs);
|
||||
Lampa.PlayerVideo.listener.follow('webos_tracks', listenWebosTracks);
|
||||
Lampa.PlayerVideo.listener.follow('canplay', canPlay);
|
||||
listenStart();
|
||||
}
|
||||
|
||||
function parseMetainfo(data) {
|
||||
var loading = Lampa.Template.get('tracks_loading');
|
||||
data.item.after(loading);
|
||||
reguest(data.element, function (result) {
|
||||
if (!result) {
|
||||
loading.addClass('tracks-loading--error').find('span').text(Lampa.Lang.translate('tracks_nometa'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (list_opened) {
|
||||
var append = function append(name, fields) {
|
||||
if (fields.length) {
|
||||
var block = Lampa.Template.get('tracks_metainfo_block', {});
|
||||
block.find('.tracks-metainfo__label').text(Lampa.Lang.translate(name == 'video' ? 'extensions_hpu_video' : name == 'audio' ? 'player_tracks' : 'player_' + name));
|
||||
fields.forEach(function (data) {
|
||||
var item = $('<div class="tracks-metainfo__item tracks-metainfo__item--' + name + ' selector"></div>');
|
||||
item.on('hover:focus', function (e) {
|
||||
Lampa.Modal.scroll().update(item);
|
||||
});
|
||||
|
||||
for (var i in data) {
|
||||
var div = $('<div class="tracks-metainfo__column--' + i + '"></div>');
|
||||
div.text(data[i]);
|
||||
item.append(div);
|
||||
}
|
||||
|
||||
block.find('.tracks-metainfo__info').append(item);
|
||||
});
|
||||
html.append(block);
|
||||
}
|
||||
};
|
||||
|
||||
var video = [];
|
||||
var audio = [];
|
||||
var subs = [];
|
||||
var codec_video = result.streams.filter(function (a) {
|
||||
return a.codec_type == 'video';
|
||||
});
|
||||
var codec_audio = result.streams.filter(function (a) {
|
||||
return a.codec_type == 'audio';
|
||||
});
|
||||
var codec_subs = result.streams.filter(function (a) {
|
||||
return a.codec_type == 'subtitle';
|
||||
});
|
||||
codec_video.slice(0, 1).forEach(function (v) {
|
||||
var line = {};
|
||||
if (v.width && v.height) line.video = v.width + 'х' + v.height;
|
||||
if (v.codec_name) line.codec = v.codec_name.toUpperCase();
|
||||
if (Boolean(v.is_avc)) line.avc = 'AVC';
|
||||
if (Lampa.Arrays.getKeys(line).length) video.push(line);
|
||||
});
|
||||
codec_audio.forEach(function (a, i) {
|
||||
var line = {
|
||||
num: i + 1
|
||||
};
|
||||
|
||||
if (a.tags) {
|
||||
line.lang = (a.tags.language || '').toUpperCase();
|
||||
}
|
||||
|
||||
line.name = a.tags ? a.tags.title || a.tags.handler_name : '';
|
||||
if (a.codec_name) line.codec = a.codec_name.toUpperCase();
|
||||
if (a.channel_layout) line.channels = a.channel_layout.replace('(side)', '').replace('stereo', '2.0');
|
||||
var bit = a.bit_rate ? a.bit_rate : a.tags && (a.tags.BPS || a.tags["BPS-eng"]) ? a.tags.BPS || a.tags["BPS-eng"] : '--';
|
||||
line.rate = bit == '--' ? bit : Math.round(bit / 1000) + ' ' + Lampa.Lang.translate('speed_kb');
|
||||
if (Lampa.Arrays.getKeys(line).length) audio.push(line);
|
||||
});
|
||||
codec_subs.forEach(function (a, i) {
|
||||
var line = {
|
||||
num: i + 1
|
||||
};
|
||||
|
||||
if (a.tags) {
|
||||
line.lang = (a.tags.language || '').toUpperCase();
|
||||
}
|
||||
|
||||
line.name = a.tags ? a.tags.title || a.tags.handler_name : '';
|
||||
if (Lampa.Arrays.getKeys(line).length) subs.push(line);
|
||||
});
|
||||
var html = Lampa.Template.get('tracks_metainfo', {});
|
||||
append('video', video);
|
||||
append('audio', audio);
|
||||
append('subs', subs);
|
||||
loading.remove();
|
||||
|
||||
if (video.length || audio.length || subs.length) {
|
||||
data.item.after(html);
|
||||
}
|
||||
|
||||
if (Lampa.Controller.enabled().name == 'modal') Lampa.Controller.toggle('modal');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Lampa.Player.listener.follow('start', function (data) {
|
||||
if (data.torrent_hash) subscribeTracks(data);
|
||||
});
|
||||
Lampa.Listener.follow('torrent_file', function (data) {
|
||||
if (data.type == 'list_open') list_opened = true;
|
||||
if (data.type == 'list_close') list_opened = false;
|
||||
|
||||
if (data.type == 'render' && data.items.length == 1 && list_opened) {
|
||||
parseMetainfo(data);
|
||||
}
|
||||
});
|
||||
Lampa.Lang.add({
|
||||
tracks_nometa: {
|
||||
ru: 'Не удалось получить сведения о дорожках',
|
||||
uk: 'Не вдалося отримати відомості про доріжки',
|
||||
en: 'Failed to get track info'
|
||||
}
|
||||
});
|
||||
Lampa.Template.add('tracks_loading', "\n <div class=\"tracks-loading\">\n <span>#{loading}...</span>\n </div>\n");
|
||||
Lampa.Template.add('tracks_metainfo', "\n <div class=\"tracks-metainfo\"></div>\n");
|
||||
Lampa.Template.add('tracks_metainfo_block', "\n <div class=\"tracks-metainfo__line\">\n <div class=\"tracks-metainfo__label\"></div>\n <div class=\"tracks-metainfo__info\"></div>\n </div>\n");
|
||||
Lampa.Template.add('tracks_css', "\n <style>\n .tracks-loading{margin-top:1em;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-moz-box-align:start;-ms-flex-align:start;align-items:flex-start}.tracks-loading:before{content:'';display:inline-block;width:1.3em;height:1.3em;background:url('./img/loader.svg') no-repeat 50% 50%;-webkit-background-size:contain;-moz-background-size:contain;-o-background-size:contain;background-size:contain;margin-right:.4em}.tracks-loading>span{font-size:1.1em;line-height:1.1}.tracks-loading--error{opacity:.5}.tracks-loading--error:before{display:none}.tracks-metainfo{margin-top:1em}.tracks-metainfo__line+.tracks-metainfo__line{margin-top:2em}.tracks-metainfo__label{opacity:.5;font-weight:600}.tracks-metainfo__info{padding-top:1em;line-height:1.2}.tracks-metainfo__info>div{background-color:rgba(0,0,0,0.22);display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-border-radius:.3em;-moz-border-radius:.3em;border-radius:.3em;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.tracks-metainfo__info>div.focus{background-color:rgba(255,255,255,0.06)}.tracks-metainfo__info>div>div{padding:1em;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.tracks-metainfo__info>div>div:not(:last-child){padding-right:1.5em}.tracks-metainfo__info>div+div{margin-top:1em}.tracks-metainfo__column--video,.tracks-metainfo__column--name{margin-right:auto}.tracks-metainfo__column--num{min-width:3em;padding-right:0}.tracks-metainfo__column--rate{min-width:7em;text-align:right}.tracks-metainfo__column--channels{min-width:5em;text-align:right}\n </style>\n");
|
||||
$('body').append(Lampa.Template.get('tracks_css', {}, true));
|
||||
|
||||
})();
|
||||
@@ -1,27 +1,53 @@
|
||||
{
|
||||
"secuses": true,
|
||||
"results": [
|
||||
{
|
||||
"title": "Lampac",
|
||||
"results": [
|
||||
{
|
||||
"name": "DLNA",
|
||||
"author": "lampac",
|
||||
"link": "{localhost}/dlna.js",
|
||||
"descr": "Каталог раздач и стриминг на DLNA-устройства (модуль Lampac)",
|
||||
"available_lampa": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Популярные",
|
||||
"results": [
|
||||
{
|
||||
"name": "BwaRC",
|
||||
"author": "bwa.ad",
|
||||
"link": "{localhost}/plugins/bwa_rc.js",
|
||||
"link": "http://bwa.ad/rc",
|
||||
"descr": "Фильмы и сериалы в онлайн",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Онлайн Мод",
|
||||
"author": "@t_anton",
|
||||
"link": "{localhost}/plugins/online_mod.js",
|
||||
"link": "https://nb557.github.io/plugins/online_mod.js",
|
||||
"descr": "Фильмы и сериалы в онлайн.",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Телевидение by Skaz ",
|
||||
"author": "@helpiptv",
|
||||
"link": "http://skaz.tv/tv.js",
|
||||
"descr": "Бесплатные каналы и 1500+ каналов по подписке, IPTV плейлисты @skaztv_bot",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "MODS's",
|
||||
"author": "@modss_group",
|
||||
"link": "http://lampa.stream/modss",
|
||||
"descr": "Фильмы и сериалы в онлайн. Плюс различные дополнения через меню настроек Modss",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Showy",
|
||||
"author": "Showy",
|
||||
"link": "{localhost}/plugins/showy.js",
|
||||
"link": "http://showy.online/m.js",
|
||||
"descr": "Фильмы и сериалы в онлайн",
|
||||
"available_lampa": 1
|
||||
}
|
||||
@@ -34,56 +60,63 @@
|
||||
{
|
||||
"name": "BwaRC",
|
||||
"author": "bwa.ad",
|
||||
"link": "{localhost}/plugins/bwa_rc.js",
|
||||
"link": "http://bwa.ad/rc",
|
||||
"descr": "Фильмы и сериалы в онлайн",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Онлайн Мод",
|
||||
"author": "@t_anton",
|
||||
"link": "{localhost}/plugins/online_mod.js",
|
||||
"link": "https://nb557.github.io/plugins/online_mod.js",
|
||||
"descr": "Фильмы и сериалы в онлайн",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Клубничка 18+",
|
||||
"author": "bwa.ad",
|
||||
"link": "{localhost}/plugins/bwa_re.js",
|
||||
"link": "http://bwa.ad/re",
|
||||
"descr": "Онлайн просмотр контента для взрослых, больше 30 сайтов",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Xsena 18+",
|
||||
"author": "xsena",
|
||||
"link": "{localhost}/plugins/xsena.js",
|
||||
"link": "http://e.xsena.red",
|
||||
"descr": "Онлайн просмотр контента для взрослых, больше 40 сайтов",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Filmix",
|
||||
"author": "@lampa_land",
|
||||
"link": "{localhost}/plugins/filmix_fx.js",
|
||||
"link": "https://lampaplugins.github.io/store/fx.js",
|
||||
"descr": "Фильмы и сериалы в онлайн",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Showy",
|
||||
"author": "Showy",
|
||||
"link": "{localhost}/plugins/showy.js",
|
||||
"link": "http://showy.online/m.js",
|
||||
"descr": "Фильмы и сериалы в онлайн",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "MODS's",
|
||||
"author": "@modss_group",
|
||||
"link": "http://lampa.stream/modss",
|
||||
"descr": "Фильмы и сериалы в онлайн. Плюс различные дополнения через меню настроек Modss",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Онлайн Stream",
|
||||
"author": "",
|
||||
"link": "{localhost}/plugins/arkmv_vod.js",
|
||||
"link": "http://arkmv.ru/vod",
|
||||
"descr": "Онлайн",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Онлайн Stream 2",
|
||||
"author": "",
|
||||
"link": "{localhost}/plugins/llpp_vod.js",
|
||||
"link": "http://llpp.in/v/vod.js",
|
||||
"descr": "Онлайн",
|
||||
"available_lampa": 1
|
||||
}
|
||||
@@ -96,14 +129,14 @@
|
||||
{
|
||||
"name": "Подборки",
|
||||
"author": "@lampa_land",
|
||||
"link": "{localhost}/plugins/podbor.js",
|
||||
"link": "https://lampaplugins.github.io/store/p.js",
|
||||
"descr": "Подборки от онлайн кинотеатров, мультсериалы и мультфильмы.",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Добавляет пункт \"Парсер\" в меню",
|
||||
"author": "@lampa",
|
||||
"link": "{localhost}/plugins/etor.js",
|
||||
"link": "http://cub.best/plugin/etor",
|
||||
"descr": "Плагин для ТВ, на которых Lampa установлена через официальные магазины LG Store и Tizen App Store. Включает в настройках отображение пунктов Парсер и Torrserver",
|
||||
"available_lampa": 1
|
||||
},
|
||||
@@ -114,6 +147,27 @@
|
||||
"descr": "Сборник публичных парсеров отображением статуса доступности",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Вторая кнопка Торренты",
|
||||
"author": "@VitalikPVA",
|
||||
"link": "{localhost}/plugins/torrents.js",
|
||||
"descr": "Плагин добавляет вторую кнопку Торренты",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Горячие кнопки",
|
||||
"author": "@nnmd",
|
||||
"link": "{localhost}/plugins/hotkeys.js",
|
||||
"descr": "Плагин вызывает меню плеера лампы по кнопкам пульта: 5 - плейлист, 8 - аудиодорожки, 0 - субтитры, channel+/- следующий/предыдущий файл в плейлисте",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Золотая Тема",
|
||||
"author": "@BazZziliuS",
|
||||
"link": "{localhost}/plugins/gold_theme.js",
|
||||
"descr": "Плагин включает золотую тему",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Дополнительные категории",
|
||||
"author": "@lme",
|
||||
@@ -121,6 +175,20 @@
|
||||
"descr": "Добавление категорий Мультфильмы, Netflix, Концерты, и прочее",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Тип управления",
|
||||
"author": "@nemiroff",
|
||||
"link": "{localhost}/plugins/select_weapon.js",
|
||||
"descr": "Позволяет выбрать тип управления (Пульт без мышки, Пульт с мышью, Тачскрин), а так же сбросить тип управления выбранный ранее",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Старый стиль (Закладки, Нравится, Позже)",
|
||||
"author": "@VitalikPVA",
|
||||
"link": "{localhost}/plugins/want.js",
|
||||
"descr": "Возвращает в главное меню старый стиль отображения пунктов (Закладки, Нравится, Позже)",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Сброс настроек субтитров",
|
||||
"author": "@t_anton",
|
||||
@@ -128,26 +196,19 @@
|
||||
"descr": "Сбрасывает настройки субтитров по умолчанию",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Мультфильмы",
|
||||
"author": "@AndreyURL54",
|
||||
"link": "{localhost}/plugins/mult.js",
|
||||
"descr": "Заменяет пункт Аниме на Мульт",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Выключение тача",
|
||||
"author": "@t_anton",
|
||||
"link": "{localhost}/plugins/not_mobile.js",
|
||||
"descr": "Выключает сенсорное управление, если оно было включено по ошибке",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "LME Torrent Manager",
|
||||
"author": "@rootu",
|
||||
"link": "{localhost}/plugins/torrentmanager.js",
|
||||
"descr": "Плагин управления торрентами",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Torrent downloader",
|
||||
"author": "@rootu",
|
||||
"link": "{localhost}/plugins/td.js",
|
||||
"descr": "Плагин управления торрентами",
|
||||
"available_lampa": 1
|
||||
}
|
||||
],
|
||||
"hpu": "control"
|
||||
@@ -155,17 +216,31 @@
|
||||
{
|
||||
"title": "Телевидение",
|
||||
"results": [
|
||||
{
|
||||
"name": "ТВ by skaz",
|
||||
"author": "@helpiptv",
|
||||
"link": "http://skaz.tv/tv.js",
|
||||
"descr": "Бесплатные каналы и 1500+ каналов по подписке, IPTV плейлисты @skaztv_bot",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Kulik",
|
||||
"author": "@SawamuraRen",
|
||||
"link": "{localhost}/plugins/kulik.js",
|
||||
"link": "http://cdn.kulik.uz/cors",
|
||||
"descr": "Просмотр IPTV каналов, отсортированных по различным категориям. Есть возможность поменять стиль плагина, сервер вещания, а также добавить каналы в избранное",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "ТВ by skaz 2.0",
|
||||
"author": "@helpiptv",
|
||||
"link": "http://skaz.tv/tv2.js",
|
||||
"descr": "Версия ТВ by skaz плагина на основе IPTV",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Дизель ТВ",
|
||||
"author": "@AndreyURL54",
|
||||
"link": "{localhost}/plugins/diesel.js",
|
||||
"link": "https://andreyurl54.github.io/diesel5/diesel.js",
|
||||
"descr": "Бесплатный просмотр телеканалов и коммерческих плейлистов с телепрограммой и записью архива",
|
||||
"available_lampa": 1
|
||||
}
|
||||
@@ -178,31 +253,38 @@
|
||||
{
|
||||
"name": "Логотипы вместо названий",
|
||||
"author": "@lampa_land",
|
||||
"link": "{localhost}/plugins/logo.js",
|
||||
"link": "https://lampaplugins.github.io/store/logo.js",
|
||||
"descr": "Отображает логотипы фильмов, вместо названий в карточках",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Кинопоиск",
|
||||
"author": "and7ey",
|
||||
"link": "{localhost}/plugins/kinopoisk.js",
|
||||
"link": "https://and7ey.github.io/lampa/kinopoisk.js",
|
||||
"descr": "Отображает в приложении список фильмов из папки Буду смотреть на Кинопоиске",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "SomaFM",
|
||||
"author": "@tsynik",
|
||||
"link": "{localhost}/plugins/soma.js",
|
||||
"link": "https://tsynik.github.io/lampa/soma.js",
|
||||
"descr": "Радио для Lampa",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Оригинальные названия",
|
||||
"author": "@BDV_Burik",
|
||||
"link": "{localhost}/plugins/title.js",
|
||||
"link": "https://bdvburik.github.io/title.js",
|
||||
"descr": "Добавляет оригинальные названия к фильмам, сериалам.",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Импорт/Экспорт закладок, истории",
|
||||
"author": "@lampa_land",
|
||||
"link": "https://skaz.tv/export.js",
|
||||
"descr": "Плагин для импорта экспорта закладок, истории",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Shikimori - Каталог аниме",
|
||||
"author": "@lme",
|
||||
@@ -213,17 +295,24 @@
|
||||
{
|
||||
"name": "Радио Рекорд (мод плагина от @Tw1cker)",
|
||||
"author": "@elenatv99",
|
||||
"link": "{localhost}/plugins/record.js",
|
||||
"link": "https://lampaplugins.github.io/store/record.js",
|
||||
"descr": "Радио Рекорд + Радио Вера)))",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Радио Records",
|
||||
"author": "@nemiroff",
|
||||
"link": "{localhost}/plugins/rr.js",
|
||||
"link": "https://nemiroff.github.io/lampa/rr.js",
|
||||
"descr": "Радио Records",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Отзывы и рецензии",
|
||||
"author": "@elenatv99",
|
||||
"link": "https://lampaplugins.github.io/store/o.js",
|
||||
"descr": "Добавляет кнопку чтения отзывов с КиноПоиск в карточку фильма или сериала",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Комментарии с HDRezka",
|
||||
"author": "@BDV_Burik",
|
||||
@@ -241,19 +330,26 @@
|
||||
{
|
||||
"name": "Предзагрузка ts",
|
||||
"author": "@rootu",
|
||||
"link": "{localhost}/plugins/ts-preload.plugin.js",
|
||||
"link": "{localhost}/plugins/ts-preload.js",
|
||||
"descr": "Плагин визуализации предзагрузки торрентов для встроенного клиента TorrServer",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Отключение Websocket",
|
||||
"author": "@rootu",
|
||||
"link": "{localhost}/plugins/wsoff.js",
|
||||
"link": "http://plugin.rootu.top/wsoff.js",
|
||||
"descr": "Плагин отключает протокол Websocket, помогает при ошибках на некоторых устройствах",
|
||||
"available_lampa": 1
|
||||
},
|
||||
{
|
||||
"name": "Поиск локального TorrServer",
|
||||
"author": "@AndreyURL54",
|
||||
"link": "{localhost}/plugins/checker.js",
|
||||
"descr": "Плагин позволяет произвести поиск вашего локального TorrServer",
|
||||
"available_lampa": 1
|
||||
}
|
||||
],
|
||||
"hpu": "other"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
"enable": true,
|
||||
"dynamic": true,
|
||||
"references": [
|
||||
"references/"
|
||||
"YoutubeExplode"
|
||||
],
|
||||
"tree": [
|
||||
"Controllers",
|
||||
"Models",
|
||||
"Providers",
|
||||
"SQL",
|
||||
"Services",
|
||||
"GlobalUsings.cs",
|
||||
"ModInit.cs"
|
||||
"ModInit.cs",
|
||||
"ModuleConf.cs"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,982 +0,0 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function getBalancerUrl() {
|
||||
var servers = [
|
||||
'http://hdpoisk.ru:2053'
|
||||
];
|
||||
return servers[Math.floor(Math.random() * servers.length)];
|
||||
}
|
||||
// ---------------------------------------------------
|
||||
|
||||
var default_host = 'http://hdpoisk.ru:2053';
|
||||
|
||||
var Defined = {
|
||||
api: 'lampac',
|
||||
localhost: default_host + '/',
|
||||
apn: ''
|
||||
};
|
||||
|
||||
var balansers_with_search;
|
||||
|
||||
var unic_id = Lampa.Storage.get('lampac_unic_id', '');
|
||||
if (!unic_id) {
|
||||
unic_id = Lampa.Utils.uid(8).toLowerCase();
|
||||
Lampa.Storage.set('lampac_unic_id', unic_id);
|
||||
}
|
||||
|
||||
function getAndroidVersion() {
|
||||
if (Lampa.Platform.is('android')) {
|
||||
try {
|
||||
var current = AndroidJS.appVersion().split('-');
|
||||
return parseInt(current.pop());
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
var hostkey = 'hdpoisk.ru:2053'; // Используем постоянный ключ для хранилища, чтобы не терять сессию
|
||||
|
||||
if (!window.rch_nws || !window.rch_nws[hostkey]) {
|
||||
if (!window.rch_nws) window.rch_nws = {};
|
||||
|
||||
window.rch_nws[hostkey] = {
|
||||
type: Lampa.Platform.is('android') ? 'apk' : Lampa.Platform.is('tizen') ? 'cors' : undefined,
|
||||
startTypeInvoke: false,
|
||||
rchRegistry: false,
|
||||
apkVersion: getAndroidVersion()
|
||||
};
|
||||
}
|
||||
|
||||
window.rch_nws[hostkey].typeInvoke = function rchtypeInvoke(host, call) {
|
||||
if (!window.rch_nws[hostkey].startTypeInvoke) {
|
||||
window.rch_nws[hostkey].startTypeInvoke = true;
|
||||
|
||||
var check = function check(good) {
|
||||
window.rch_nws[hostkey].type = Lampa.Platform.is('android') ? 'apk' : good ? 'cors' : 'web';
|
||||
call();
|
||||
};
|
||||
|
||||
if (Lampa.Platform.is('android') || Lampa.Platform.is('tizen')) check(true);
|
||||
else {
|
||||
var net = new Lampa.Reguest();
|
||||
// Тут тоже используем динамический хост для проверки
|
||||
var check_url = getBalancerUrl();
|
||||
net.silent(check_url.indexOf(location.host) >= 0 ? 'https://github.com/' : check_url + '/cors/check', function() {
|
||||
check(true);
|
||||
}, function() {
|
||||
check(false);
|
||||
}, false, {
|
||||
dataType: 'text'
|
||||
});
|
||||
}
|
||||
} else call();
|
||||
};
|
||||
|
||||
window.rch_nws[hostkey].Registry = function RchRegistry(client, startConnection) {
|
||||
// Вызываем с динамическим хостом
|
||||
window.rch_nws[hostkey].typeInvoke(getBalancerUrl(), function() {
|
||||
|
||||
client.invoke("RchRegistry", JSON.stringify({
|
||||
version: 149,
|
||||
host: location.host,
|
||||
rchtype: Lampa.Platform.is('android') ? 'apk' : Lampa.Platform.is('tizen') ? 'cors' : window.rch_nws[hostkey].type,
|
||||
apkVersion: window.rch_nws[hostkey].apkVersion,
|
||||
player: Lampa.Storage.field('player')
|
||||
}));
|
||||
|
||||
if (client._shouldReconnect && window.rch_nws[hostkey].rchRegistry) {
|
||||
if (startConnection) startConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
window.rch_nws[hostkey].rchRegistry = true;
|
||||
|
||||
client.on('RchRegistry', function(clientIp) {
|
||||
if (startConnection) startConnection();
|
||||
});
|
||||
|
||||
client.on("RchClient", function(rchId, url, data, headers, returnHeaders) {
|
||||
var network = new Lampa.Reguest();
|
||||
|
||||
function result(html) {
|
||||
if (Lampa.Arrays.isObject(html) || Lampa.Arrays.isArray(html)) {
|
||||
html = JSON.stringify(html);
|
||||
}
|
||||
|
||||
if (typeof CompressionStream !== 'undefined' && html && html.length > 1000) {
|
||||
var compressionStream = new CompressionStream('gzip');
|
||||
var encoder = new TextEncoder();
|
||||
var readable = new ReadableStream({
|
||||
start: function(controller) {
|
||||
controller.enqueue(encoder.encode(html));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
var compressedStream = readable.pipeThrough(compressionStream);
|
||||
new Response(compressedStream).arrayBuffer()
|
||||
.then(function(compressedBuffer) {
|
||||
var compressedArray = new Uint8Array(compressedBuffer);
|
||||
if (compressedArray.length > html.length) {
|
||||
client.invoke("RchResult", rchId, html);
|
||||
} else {
|
||||
$.ajax({
|
||||
// Используем динамический хост для POST результата
|
||||
url: getBalancerUrl() + '/rch/gzresult?id=' + rchId,
|
||||
type: 'POST',
|
||||
data: compressedArray,
|
||||
async: true,
|
||||
cache: false,
|
||||
contentType: false,
|
||||
processData: false,
|
||||
success: function(j) {},
|
||||
error: function() {
|
||||
client.invoke("RchResult", rchId, html);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
client.invoke("RchResult", rchId, html);
|
||||
});
|
||||
|
||||
} else {
|
||||
client.invoke("RchResult", rchId, html);
|
||||
}
|
||||
}
|
||||
|
||||
if (url == 'eval') {
|
||||
console.log('RCH', url, data);
|
||||
result(eval(data));
|
||||
} else if (url == 'ping') {
|
||||
result('pong');
|
||||
} else {
|
||||
console.log('RCH', url);
|
||||
network["native"](url, result, function() {
|
||||
console.log('RCH', 'result empty');
|
||||
result('');
|
||||
}, data, {
|
||||
dataType: 'text',
|
||||
timeout: 1000 * 8,
|
||||
headers: headers,
|
||||
returnHeaders: returnHeaders
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
client.on('Connected', function(connectionId) {
|
||||
console.log('RCH', 'ConnectionId: ' + connectionId);
|
||||
window.rch_nws[hostkey].connectionId = connectionId;
|
||||
});
|
||||
client.on('Closed', function() {
|
||||
console.log('RCH', 'Connection closed');
|
||||
});
|
||||
client.on('Error', function(err) {
|
||||
console.log('RCH', 'error:', err);
|
||||
});
|
||||
});
|
||||
};
|
||||
window.rch_nws[hostkey].typeInvoke(default_host, function() {});
|
||||
|
||||
function rchInvoke(json, call) {
|
||||
if (window.nwsClient && window.nwsClient[hostkey] && window.nwsClient[hostkey]._shouldReconnect){
|
||||
call();
|
||||
return;
|
||||
}
|
||||
if (!window.nwsClient) window.nwsClient = {};
|
||||
if (window.nwsClient[hostkey] && window.nwsClient[hostkey].socket)
|
||||
window.nwsClient[hostkey].socket.close();
|
||||
window.nwsClient[hostkey] = new NativeWsClient(json.nws, {
|
||||
autoReconnect: false
|
||||
});
|
||||
window.nwsClient[hostkey].on('Connected', function(connectionId) {
|
||||
window.rch_nws[hostkey].Registry(window.nwsClient[hostkey], function() {
|
||||
call();
|
||||
});
|
||||
});
|
||||
window.nwsClient[hostkey].connect();
|
||||
}
|
||||
|
||||
function rchRun(json, call) {
|
||||
if (typeof NativeWsClient == 'undefined') {
|
||||
// Используем динамический хост для загрузки скрипта клиента
|
||||
Lampa.Utils.putScript([getBalancerUrl() + "/js/nws-client-es5.js?v18112025"], function() {}, false, function() {
|
||||
rchInvoke(json, call);
|
||||
}, true);
|
||||
} else {
|
||||
rchInvoke(json, call);
|
||||
}
|
||||
}
|
||||
|
||||
function account(url) {
|
||||
url = url + '';
|
||||
|
||||
// --- ДИНАМИЧЕСКАЯ ПОДМЕНА ХОСТА ---
|
||||
// При каждом вызове account, мы берем новый случайный сервер
|
||||
// и заменяем любые известные домены на него.
|
||||
var random_host = getBalancerUrl();
|
||||
|
||||
// Список доменов, которые мы хотим подменять на лету
|
||||
var replaceable = [
|
||||
'http://hdpoisk.ru:2053'
|
||||
];
|
||||
|
||||
for (var i = 0; i < replaceable.length; i++) {
|
||||
if (url.indexOf(replaceable[i]) !== -1) {
|
||||
url = url.replace(replaceable[i], random_host);
|
||||
break; // Заменили один раз и выходим
|
||||
}
|
||||
}
|
||||
// ----------------------------------
|
||||
|
||||
if (url.indexOf('account_email=') == -1) {
|
||||
var email = Lampa.Storage.get('account_email');
|
||||
if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email));
|
||||
}
|
||||
if (url.indexOf('uid=') == -1) {
|
||||
var uid = Lampa.Storage.get('lampac_unic_id', '');
|
||||
if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid));
|
||||
}
|
||||
if (url.indexOf('token=') == -1) {
|
||||
var token = '';
|
||||
if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token=');
|
||||
}
|
||||
if (url.indexOf('nws_id=') == -1 && window.rch_nws && window.rch_nws[hostkey]) {
|
||||
var nws_id = window.rch_nws[hostkey].connectionId || '';
|
||||
if (nws_id) url = Lampa.Utils.addUrlComponent(url, 'nws_id=' + encodeURIComponent(nws_id));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
var Network = Lampa.Reguest;
|
||||
|
||||
function component(object) {
|
||||
var network = new Network();
|
||||
var scroll = new Lampa.Scroll({
|
||||
mask: true,
|
||||
over: true
|
||||
});
|
||||
var files = new Lampa.Explorer(object);
|
||||
var filter = new Lampa.Filter(object);
|
||||
var sources = {};
|
||||
var last;
|
||||
var source;
|
||||
var balanser;
|
||||
var initialized;
|
||||
var balanser_timer;
|
||||
var images = [];
|
||||
var number_of_requests = 0;
|
||||
var number_of_requests_timer;
|
||||
var life_wait_times = 0;
|
||||
var life_wait_timer;
|
||||
var filter_sources = {};
|
||||
var filter_translate = {
|
||||
season: Lampa.Lang.translate('torrent_serial_season'),
|
||||
voice: Lampa.Lang.translate('torrent_parser_voice'),
|
||||
source: Lampa.Lang.translate('settings_rest_source')
|
||||
};
|
||||
var filter_find = {
|
||||
season: [],
|
||||
voice: []
|
||||
};
|
||||
|
||||
if (balansers_with_search == undefined) {
|
||||
network.timeout(10000);
|
||||
network.silent(account(Defined.localhost + 'lite/withsearch'), function(json) {
|
||||
balansers_with_search = json;
|
||||
}, function() {
|
||||
balansers_with_search = [];
|
||||
});
|
||||
}
|
||||
|
||||
function balanserName(j) {
|
||||
var bals = j.balanser;
|
||||
var name = j.name.split(' ')[0];
|
||||
return (bals || name).toLowerCase();
|
||||
}
|
||||
|
||||
function clarificationSearchAdd(value){
|
||||
var id = Lampa.Utils.hash(object.movie.number_of_seasons ? object.movie.original_name : object.movie.original_title);
|
||||
var all = Lampa.Storage.get('clarification_search','{}');
|
||||
|
||||
all[id] = value;
|
||||
|
||||
Lampa.Storage.set('clarification_search',all);
|
||||
}
|
||||
|
||||
function clarificationSearchDelete(){
|
||||
var id = Lampa.Utils.hash(object.movie.number_of_seasons ? object.movie.original_name : object.movie.original_title);
|
||||
var all = Lampa.Storage.get('clarification_search','{}');
|
||||
|
||||
delete all[id];
|
||||
|
||||
Lampa.Storage.set('clarification_search',all);
|
||||
}
|
||||
|
||||
function clarificationSearchGet(){
|
||||
var id = Lampa.Utils.hash(object.movie.number_of_seasons ? object.movie.original_name : object.movie.original_title);
|
||||
var all = Lampa.Storage.get('clarification_search','{}');
|
||||
|
||||
return all[id];
|
||||
}
|
||||
|
||||
this.initialize = function() {
|
||||
var _this = this;
|
||||
this.loading(true);
|
||||
filter.onSearch = function(value) {
|
||||
|
||||
clarificationSearchAdd(value);
|
||||
|
||||
Lampa.Activity.replace({
|
||||
search: value,
|
||||
clarification: true,
|
||||
similar: true
|
||||
});
|
||||
};
|
||||
filter.onBack = function() {
|
||||
_this.start();
|
||||
};
|
||||
filter.render().find('.selector').on('hover:enter', function() {
|
||||
clearInterval(balanser_timer);
|
||||
});
|
||||
filter.render().find('.filter--search').appendTo(filter.render().find('.torrent-filter'));
|
||||
filter.onSelect = function(type, a, b) {
|
||||
if (type == 'filter') {
|
||||
if (a.reset) {
|
||||
clarificationSearchDelete();
|
||||
|
||||
_this.replaceChoice({
|
||||
season: 0,
|
||||
voice: 0,
|
||||
voice_url: '',
|
||||
voice_name: ''
|
||||
});
|
||||
setTimeout(function() {
|
||||
Lampa.Select.close();
|
||||
Lampa.Activity.replace({
|
||||
clarification: 0,
|
||||
similar: 0
|
||||
});
|
||||
}, 10);
|
||||
} else {
|
||||
var url = filter_find[a.stype][b.index].url;
|
||||
var choice = _this.getChoice();
|
||||
if (a.stype == 'voice') {
|
||||
choice.voice_name = filter_find.voice[b.index].title;
|
||||
choice.voice_url = url;
|
||||
}
|
||||
choice[a.stype] = b.index;
|
||||
_this.saveChoice(choice);
|
||||
_this.reset();
|
||||
_this.request(url);
|
||||
setTimeout(Lampa.Select.close, 10);
|
||||
}
|
||||
} else if (type == 'sort') {
|
||||
Lampa.Select.close();
|
||||
object.lampac_custom_select = a.source;
|
||||
_this.changeBalanser(a.source);
|
||||
}
|
||||
};
|
||||
if (filter.addButtonBack) filter.addButtonBack();
|
||||
filter.render().find('.filter--sort span').text(Lampa.Lang.translate('lampac_balanser'));
|
||||
scroll.body().addClass('torrent-list');
|
||||
files.appendFiles(scroll.render());
|
||||
files.appendHead(filter.render());
|
||||
scroll.minus(files.render().find('.explorer__files-head'));
|
||||
scroll.body().append(Lampa.Template.get('lampac_content_loading'));
|
||||
Lampa.Controller.enable('content');
|
||||
this.loading(false);
|
||||
if(object.balanser){
|
||||
files.render().find('.filter--search').remove();
|
||||
sources = {};
|
||||
sources[object.balanser] = {name: object.balanser};
|
||||
balanser = object.balanser;
|
||||
filter_sources = [];
|
||||
|
||||
return network["native"](account(object.url.replace('rjson=','nojson=')), this.parse.bind(this), function(){
|
||||
files.render().find('.torrent-filter').remove();
|
||||
_this.empty();
|
||||
}, false, {
|
||||
dataType: 'text'
|
||||
});
|
||||
}
|
||||
this.externalids().then(function() {
|
||||
return _this.createSource();
|
||||
}).then(function(json) {
|
||||
if (!balansers_with_search.find(function(b) {
|
||||
return balanser.slice(0, b.length) == b;
|
||||
})) {
|
||||
filter.render().find('.filter--search').addClass('hide');
|
||||
}
|
||||
_this.search();
|
||||
})["catch"](function(e) {
|
||||
_this.noConnectToServer(e);
|
||||
});
|
||||
};
|
||||
this.rch = function(json, noreset) {
|
||||
var _this2 = this;
|
||||
rchRun(json, function() {
|
||||
if (!noreset) _this2.find();
|
||||
else noreset();
|
||||
});
|
||||
};
|
||||
this.externalids = function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!object.movie.imdb_id || !object.movie.kinopoisk_id) {
|
||||
var query = [];
|
||||
query.push('id=' + encodeURIComponent(object.movie.id));
|
||||
query.push('serial=' + (object.movie.name ? 1 : 0));
|
||||
if (object.movie.imdb_id) query.push('imdb_id=' + (object.movie.imdb_id || ''));
|
||||
if (object.movie.kinopoisk_id) query.push('kinopoisk_id=' + (object.movie.kinopoisk_id || ''));
|
||||
var url = Defined.localhost + 'externalids?' + query.join('&');
|
||||
network.timeout(10000);
|
||||
network.silent(account(url), function(json) {
|
||||
for (var name in json) {
|
||||
object.movie[name] = json[name];
|
||||
}
|
||||
resolve();
|
||||
}, function() {
|
||||
resolve();
|
||||
});
|
||||
} else resolve();
|
||||
});
|
||||
};
|
||||
this.updateBalanser = function(balanser_name) {
|
||||
var last_select_balanser = Lampa.Storage.cache('online_last_balanser', 3000, {});
|
||||
last_select_balanser[object.movie.id] = balanser_name;
|
||||
Lampa.Storage.set('online_last_balanser', last_select_balanser);
|
||||
};
|
||||
this.changeBalanser = function(balanser_name) {
|
||||
this.updateBalanser(balanser_name);
|
||||
Lampa.Storage.set('online_balanser', balanser_name);
|
||||
var to = this.getChoice(balanser_name);
|
||||
var from = this.getChoice();
|
||||
if (from.voice_name) to.voice_name = from.voice_name;
|
||||
this.saveChoice(to, balanser_name);
|
||||
Lampa.Activity.replace();
|
||||
};
|
||||
this.requestParams = function(url) {
|
||||
var query = [];
|
||||
var card_source = object.movie.source || 'tmdb'; //Lampa.Storage.field('source')
|
||||
query.push('id=' + encodeURIComponent(object.movie.id));
|
||||
if (object.movie.imdb_id) query.push('imdb_id=' + (object.movie.imdb_id || ''));
|
||||
if (object.movie.kinopoisk_id) query.push('kinopoisk_id=' + (object.movie.kinopoisk_id || ''));
|
||||
if (object.movie.tmdb_id) query.push('tmdb_id=' + (object.movie.tmdb_id || ''));
|
||||
query.push('title=' + encodeURIComponent(object.clarification ? object.search : object.movie.title || object.movie.name));
|
||||
query.push('original_title=' + encodeURIComponent(object.movie.original_title || object.movie.original_name));
|
||||
query.push('serial=' + (object.movie.name ? 1 : 0));
|
||||
query.push('original_language=' + (object.movie.original_language || ''));
|
||||
query.push('year=' + ((object.movie.release_date || object.movie.first_air_date || '0000') + '').slice(0, 4));
|
||||
query.push('source=' + card_source);
|
||||
query.push('clarification=' + (object.clarification ? 1 : 0));
|
||||
query.push('similar=' + (object.similar ? true : false));
|
||||
query.push('rchtype=' + (((window.rch_nws && window.rch_nws[hostkey]) ? window.rch_nws[hostkey].type : (window.rch && window.rch[hostkey]) ? window.rch[hostkey].type : '') || ''));
|
||||
if (Lampa.Storage.get('account_email', '')) query.push('cub_id=' + Lampa.Utils.hash(Lampa.Storage.get('account_email', '')));
|
||||
return url + (url.indexOf('?') >= 0 ? '&' : '?') + query.join('&');
|
||||
};
|
||||
this.getLastChoiceBalanser = function() {
|
||||
var last_select_balanser = Lampa.Storage.cache('online_last_balanser', 3000, {});
|
||||
if (last_select_balanser[object.movie.id]) {
|
||||
return last_select_balanser[object.movie.id];
|
||||
} else {
|
||||
return Lampa.Storage.get('online_balanser', filter_sources.length ? filter_sources[0] : '');
|
||||
}
|
||||
};
|
||||
this.startSource = function(json) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
json.forEach(function(j) {
|
||||
var name = balanserName(j);
|
||||
sources[name] = {
|
||||
url: j.url,
|
||||
name: j.name,
|
||||
show: typeof j.show == 'undefined' ? true : j.show
|
||||
};
|
||||
});
|
||||
filter_sources = Lampa.Arrays.getKeys(sources);
|
||||
if (filter_sources.length) {
|
||||
var last_select_balanser = Lampa.Storage.cache('online_last_balanser', 3000, {});
|
||||
if (last_select_balanser[object.movie.id]) {
|
||||
balanser = last_select_balanser[object.movie.id];
|
||||
} else {
|
||||
balanser = Lampa.Storage.get('online_balanser', filter_sources[0]);
|
||||
}
|
||||
if (!sources[balanser]) balanser = filter_sources[0];
|
||||
if (!sources[balanser].show && !object.lampac_custom_select) balanser = filter_sources[0];
|
||||
source = sources[balanser].url;
|
||||
Lampa.Storage.set('active_balanser', balanser);
|
||||
resolve(json);
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
});
|
||||
};
|
||||
this.lifeSource = function() {
|
||||
var _this3 = this;
|
||||
return new Promise(function(resolve, reject) {
|
||||
var url = _this3.requestParams(Defined.localhost + 'lifeevents?memkey=' + (_this3.memkey || ''));
|
||||
var red = false;
|
||||
var gou = function gou(json, any) {
|
||||
if (json.accsdb) return reject(json);
|
||||
var last_balanser = _this3.getLastChoiceBalanser();
|
||||
if (!red) {
|
||||
var _filter = json.online.filter(function(c) {
|
||||
return any ? c.show : c.show && c.name.toLowerCase() == last_balanser;
|
||||
});
|
||||
if (_filter.length) {
|
||||
red = true;
|
||||
resolve(json.online.filter(function(c) {
|
||||
return c.show;
|
||||
}));
|
||||
} else if (any) {
|
||||
reject();
|
||||
}
|
||||
}
|
||||
};
|
||||
var fin = function fin(call) {
|
||||
network.timeout(3000);
|
||||
network.silent(account(url), function(json) {
|
||||
life_wait_times++;
|
||||
filter_sources = [];
|
||||
sources = {};
|
||||
json.online.forEach(function(j) {
|
||||
var name = balanserName(j);
|
||||
sources[name] = {
|
||||
url: j.url,
|
||||
name: j.name,
|
||||
show: typeof j.show == 'undefined' ? true : j.show
|
||||
};
|
||||
});
|
||||
filter_sources = Lampa.Arrays.getKeys(sources);
|
||||
filter.set('sort', filter_sources.map(function(e) {
|
||||
return {
|
||||
title: sources[e].name,
|
||||
source: e,
|
||||
selected: e == balanser,
|
||||
ghost: !sources[e].show
|
||||
};
|
||||
}));
|
||||
filter.chosen('sort', [sources[balanser] ? sources[balanser].name : balanser]);
|
||||
gou(json);
|
||||
var lastb = _this3.getLastChoiceBalanser();
|
||||
if (life_wait_times > 15 || json.ready) {
|
||||
filter.render().find('.lampac-balanser-loader').remove();
|
||||
gou(json, true);
|
||||
} else if (!red && sources[lastb] && sources[lastb].show) {
|
||||
gou(json, true);
|
||||
life_wait_timer = setTimeout(fin, 1000);
|
||||
} else {
|
||||
life_wait_timer = setTimeout(fin, 1000);
|
||||
}
|
||||
}, function() {
|
||||
life_wait_times++;
|
||||
if (life_wait_times > 15) {
|
||||
reject();
|
||||
} else {
|
||||
life_wait_timer = setTimeout(fin, 1000);
|
||||
}
|
||||
});
|
||||
};
|
||||
fin();
|
||||
});
|
||||
};
|
||||
this.createSource = function() {
|
||||
var _this4 = this;
|
||||
return new Promise(function(resolve, reject) {
|
||||
var url = _this4.requestParams(Defined.localhost + 'lite/events?life=true');
|
||||
network.timeout(15000);
|
||||
network.silent(account(url), function(json) {
|
||||
if (json.accsdb) return reject(json);
|
||||
if (json.life) {
|
||||
_this4.memkey = json.memkey;
|
||||
if (json.title) {
|
||||
if (object.movie.name) object.movie.name = json.title;
|
||||
if (object.movie.title) object.movie.title = json.title;
|
||||
}
|
||||
filter.render().find('.filter--sort').append('<span class="lampac-balanser-loader" style="width: 1.2em; height: 1.2em; margin-top: 0; background: url(./img/loader.svg) no-repeat 50% 50%; background-size: contain; margin-left: 0.5em"></span>');
|
||||
_this4.lifeSource().then(_this4.startSource).then(resolve)["catch"](reject);
|
||||
} else {
|
||||
_this4.startSource(json).then(resolve)["catch"](reject);
|
||||
}
|
||||
}, reject);
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Подготовка
|
||||
*/
|
||||
this.create = function() {
|
||||
return this.render();
|
||||
};
|
||||
/**
|
||||
* Начать поиск
|
||||
*/
|
||||
this.search = function() { //this.loading(true)
|
||||
this.filter({
|
||||
source: filter_sources
|
||||
}, this.getChoice());
|
||||
this.find();
|
||||
};
|
||||
this.find = function() {
|
||||
this.request(this.requestParams(source));
|
||||
};
|
||||
this.request = function(url) {
|
||||
number_of_requests++;
|
||||
if (number_of_requests < 10) {
|
||||
network["native"](account(url), this.parse.bind(this), this.doesNotAnswer.bind(this), false, {
|
||||
dataType: 'text'
|
||||
});
|
||||
clearTimeout(number_of_requests_timer);
|
||||
number_of_requests_timer = setTimeout(function() {
|
||||
number_of_requests = 0;
|
||||
}, 4000);
|
||||
} else this.empty();
|
||||
};
|
||||
this.parseJsonDate = function(str, name) {
|
||||
try {
|
||||
var html = $('<div>' + str + '</div>');
|
||||
var elems = [];
|
||||
html.find(name).each(function() {
|
||||
var item = $(this);
|
||||
var data = JSON.parse(item.attr('data-json'));
|
||||
var season = item.attr('s');
|
||||
var episode = item.attr('e');
|
||||
var text = item.text();
|
||||
if (!object.movie.name) {
|
||||
if (text.match(/\d+p/i)) {
|
||||
if (!data.quality) {
|
||||
data.quality = {};
|
||||
data.quality[text] = data.url;
|
||||
}
|
||||
text = object.movie.title;
|
||||
}
|
||||
if (text == 'По умолчанию') {
|
||||
text = object.movie.title;
|
||||
}
|
||||
}
|
||||
if (episode) data.episode = parseInt(episode);
|
||||
if (season) data.season = parseInt(season);
|
||||
if (text) data.text = text;
|
||||
data.active = item.hasClass('active');
|
||||
elems.push(data);
|
||||
});
|
||||
return elems;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
this.getFileUrl = function(file, call, waiting_rch) {
|
||||
var _this = this;
|
||||
|
||||
if(Lampa.Storage.field('player') !== 'inner' && file.stream && Lampa.Platform.is('apple')){
|
||||
var newfile = Lampa.Arrays.clone(file);
|
||||
newfile.method = 'play';
|
||||
newfile.url = file.stream;
|
||||
call(newfile, {});
|
||||
}
|
||||
else if (file.method == 'play') call(file, {});
|
||||
else {
|
||||
Lampa.Loading.start(function() {
|
||||
Lampa.Loading.stop();
|
||||
Lampa.Controller.toggle('content');
|
||||
network.clear();
|
||||
});
|
||||
network["native"](account(file.url), function(json) {
|
||||
if(json.rch){
|
||||
if(waiting_rch) {
|
||||
Lampa.Loading.stop();
|
||||
call(false, {});
|
||||
}
|
||||
else {
|
||||
_this.rch(json,function(){
|
||||
Lampa.Loading.stop();
|
||||
|
||||
_this.getFileUrl(file, call, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
else{
|
||||
Lampa.Loading.stop();
|
||||
call(json, json);
|
||||
}
|
||||
}, function() {
|
||||
Lampa.Loading.stop();
|
||||
call(false, {});
|
||||
});
|
||||
}
|
||||
};
|
||||
this.toPlayElement = function(file) {
|
||||
var play = {
|
||||
title: file.title,
|
||||
url: file.url,
|
||||
quality: file.qualitys,
|
||||
timeline: file.timeline,
|
||||
subtitles: file.subtitles,
|
||||
segments: file.segments,
|
||||
callback: file.mark,
|
||||
season: file.season,
|
||||
episode: file.episode,
|
||||
voice_name: file.voice_name
|
||||
};
|
||||
return play;
|
||||
};
|
||||
this.orUrlReserve = function(data) {
|
||||
if (data.url && typeof data.url == 'string' && data.url.indexOf(" or ") !== -1) {
|
||||
var urls = data.url.split(" or ");
|
||||
data.url = urls[0];
|
||||
data.url_reserve = urls[1];
|
||||
}
|
||||
};
|
||||
this.setDefaultQuality = function(data) {
|
||||
if (Lampa.Arrays.getKeys(data.quality).length) {
|
||||
for (var q in data.quality) {
|
||||
if (parseInt(q) == Lampa.Storage.field('video_quality_default')) {
|
||||
data.url = data.quality[q];
|
||||
this.orUrlReserve(data);
|
||||
}
|
||||
if (data.quality[q].indexOf(" or ") !== -1)
|
||||
data.quality[q] = data.quality[q].split(" or ")[0];
|
||||
}
|
||||
}
|
||||
};
|
||||
this.display = function(videos) {
|
||||
var _this5 = this;
|
||||
this.draw(videos, {
|
||||
onEnter: function onEnter(item, html) {
|
||||
_this5.getFileUrl(item, function(json, json_call) {
|
||||
if (json && json.url) {
|
||||
var playlist = [];
|
||||
var first = _this5.toPlayElement(item);
|
||||
first.url = json.url;
|
||||
first.headers = json_call.headers || json.headers;
|
||||
first.quality = json_call.quality || item.qualitys;
|
||||
first.segments = json_call.segments || item.segments;
|
||||
first.hls_manifest_timeout = json_call.hls_manifest_timeout || json.hls_manifest_timeout;
|
||||
first.subtitles = json.subtitles;
|
||||
first.subtitles_call = json_call.subtitles_call || json.subtitles_call;
|
||||
if (json.vast && json.vast.url) {
|
||||
first.vast_url = json.vast.url;
|
||||
first.vast_msg = json.vast.msg;
|
||||
first.vast_region = json.vast.region;
|
||||
first.vast_platform = json.vast.platform;
|
||||
first.vast_screen = json.vast.screen;
|
||||
}
|
||||
_this5.orUrlReserve(first);
|
||||
_this5.setDefaultQuality(first);
|
||||
if (item.season) {
|
||||
videos.forEach(function(elem) {
|
||||
var cell = _this5.toPlayElement(elem);
|
||||
if (elem == item) cell.url = json.url;
|
||||
else {
|
||||
if (elem.method == 'call') {
|
||||
if (Lampa.Storage.field('player') !== 'inner') {
|
||||
cell.url = elem.stream;
|
||||
delete cell.quality;
|
||||
} else {
|
||||
cell.url = function(call) {
|
||||
_this5.getFileUrl(elem, function(stream, stream_json) {
|
||||
if (stream.url) {
|
||||
cell.url = stream.url;
|
||||
cell.quality = stream_json.quality || elem.qualitys;
|
||||
cell.segments = stream_json.segments || elem.segments;
|
||||
cell.subtitles = stream.subtitles;
|
||||
_this5.orUrlReserve(cell);
|
||||
_this5.setDefaultQuality(cell);
|
||||
elem.mark();
|
||||
} else {
|
||||
cell.url = '';
|
||||
Lampa.Noty.show(Lampa.Lang.translate('lampac_nolink'));
|
||||
}
|
||||
call();
|
||||
}, function() {
|
||||
cell.url = '';
|
||||
call();
|
||||
});
|
||||
};
|
||||
}
|
||||
} else {
|
||||
cell.url = elem.url;
|
||||
}
|
||||
}
|
||||
_this5.orUrlReserve(cell);
|
||||
_this5.setDefaultQuality(cell);
|
||||
playlist.push(cell);
|
||||
}); //Lampa.Player.playlist(playlist)
|
||||
} else {
|
||||
playlist.push(first);
|
||||
}
|
||||
if (playlist.length > 1) first.playlist = playlist;
|
||||
if (first.url) {
|
||||
var element = first;
|
||||
element.isonline = true;
|
||||
if (element.url && element.isonline) {
|
||||
// online.js
|
||||
}
|
||||
else if (element.url) {
|
||||
if (false) {
|
||||
if (Platform.is('browser') && location.host.indexOf("127.0.0.1") !== -1) {
|
||||
Noty.show('Видео открыто в playerInner', {time: 3000});
|
||||
$.get(getBalancerUrl() + '/player-inner/' + element.url);
|
||||
return;
|
||||
}
|
||||
|
||||
Player.play(element);
|
||||
}
|
||||
else {
|
||||
if (true && Platform.is('browser') && location.host.indexOf("127.0.0.1") !== -1)
|
||||
Noty.show('Внешний плеер можно указать в init.conf (playerInner)', {time: 3000});
|
||||
Player.play(element);
|
||||
}
|
||||
}
|
||||
Lampa.Player.play(element);
|
||||
Lampa.Player.playlist(playlist);
|
||||
if(element.subtitles_call) _this5.loadSubtitles(element.subtitles_call)
|
||||
item.mark();
|
||||
_this5.updateBalanser(balanser);
|
||||
} else {
|
||||
Lampa.Noty.show(Lampa.Lang.translate('lampac_nolink'));
|
||||
}
|
||||
} else Lampa.Noty.show(Lampa.Lang.translate('lampac_nolink'));
|
||||
}, true);
|
||||
},
|
||||
onContextMenu: function onContextMenu(item, html, data, call) {
|
||||
_this5.getFileUrl(item, function(stream) {
|
||||
call({
|
||||
file: stream.url,
|
||||
quality: item.qualitys
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
this.filter({
|
||||
season: filter_find.season.map(function(s) {
|
||||
return s.title;
|
||||
}),
|
||||
voice: filter_find.voice.map(function(b) {
|
||||
return b.title;
|
||||
})
|
||||
}, this.getChoice());
|
||||
};
|
||||
this.loadSubtitles = function(link){
|
||||
network.silent(account(link), function(subs){
|
||||
Lampa.Player.subtitles(subs)
|
||||
})
|
||||
}
|
||||
this.parse = function(str) {
|
||||
var json = Lampa.Arrays.decodeJson(str, {});
|
||||
if (Lampa.Arrays.isObject(str) && str.rch) json = str;
|
||||
if (json.rch) return this.rch(json);
|
||||
try {
|
||||
var items = this.parseJsonDate(str, '.videos__item');
|
||||
var buttons = this.parseJsonDate(str, '.videos__button');
|
||||
if (items.length == 1 && items[0].method == 'link' && !items[0].similar) {
|
||||
filter_find.season = items.map(function(s) {
|
||||
return {
|
||||
title: s.text,
|
||||
url: s.url
|
||||
};
|
||||
});
|
||||
this.replaceChoice({
|
||||
season: 0
|
||||
});
|
||||
this.request(items[0].url);
|
||||
} else {
|
||||
this.activity.loader(false);
|
||||
var videos = items.filter(function(v) {
|
||||
return v.method == 'play' || v.method == 'call';
|
||||
});
|
||||
var similar = items.filter(function(v) {
|
||||
return v.similar;
|
||||
});
|
||||
if (videos.length) {
|
||||
if (buttons.length) {
|
||||
filter_find.voice = buttons.map(function(b) {
|
||||
return {
|
||||
title: b.text,
|
||||
url: b.url
|
||||
};
|
||||
});
|
||||
var select_voice_url = this.getChoice(balanser).voice_url;
|
||||
var select_voice_name = this.getChoice(balanser).voice_name;
|
||||
var find_voice_url = buttons.find(function(v) {
|
||||
return v.url == select_voice_url;
|
||||
});
|
||||
var find_voice_name = buttons.find(function(v) {
|
||||
return v.text == select_voice_name;
|
||||
});
|
||||
var find_voice_active = buttons.find(function(v) {
|
||||
return v.active;
|
||||
}); ////console.log('b',buttons)
|
||||
////console.log('u',find_voice_url)
|
||||
////console.log('n',find_voice_name)
|
||||
////console.log('a',find_voice_active)
|
||||
if (find_voice_url && !find_voice_url.active) {
|
||||
//console.log('Lampac', 'go to voice', find_voice_url);
|
||||
this.replaceChoice({
|
||||
voice: buttons.indexOf(find_voice_url),
|
||||
voice_name: find_voice_url.text
|
||||
});
|
||||
this.request(find_voice_url.url);
|
||||
} else if (find_voice_name && !find_voice_name.active) {
|
||||
//console.log('Lampac', 'go to voice', find_voice_name);
|
||||
this.replaceChoice({
|
||||
voice: buttons.indexOf(find_voice_name),
|
||||
voice_name: find_voice_name.text
|
||||
});
|
||||
this.request(find_voice_name.url);
|
||||
} else {
|
||||
if (find_voice_active) {
|
||||
this.replaceChoice({
|
||||
voice: buttons.indexOf(find_voice_active),
|
||||
voice_name: find_voice_active.text
|
||||
});
|
||||
}
|
||||
this.display(videos);
|
||||
}
|
||||
} else {
|
||||
this.replaceChoice({
|
||||
voice: 0,
|
||||
voice_url: '',
|
||||
voice_name: ''
|
||||
});
|
||||
this.display(videos);
|
||||
}
|
||||
} else if (items.length) {
|
||||
if (similar.length) {
|
||||
this.similars(similar);
|
||||
this.activity.loader(false);
|
||||
} else { //this.activity.loader(true)
|
||||
filter_find.season = items.map(function(s) {
|
||||
return {
|
||||
title: s.text,
|
||||
url: s.url
|
||||
};
|
||||
});
|
||||
var select_season = this.getChoice(balanser).season;
|
||||
var season = filter_find.season[select_season];
|
||||
if (!season) season = filter_find.season[0];
|
||||
//console.log('Lampac', 'go to season', season);
|
||||
this.request(season.url);
|
||||
}
|
||||
} else {
|
||||
this.doesNotAnswer(json);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
//console.log('Lampac', 'error', e.stack);
|
||||
this.doesNotAnswer(e);
|
||||
}
|
||||
};
|
||||
this.similars = function(json) {
|
||||
var _this6 = this;
|
||||
scroll.clear();
|
||||
json.forEach(function(elem) {
|
||||
elem.title = elem.text;
|
||||
elem.info = '';
|
||||
var info = [];
|
||||
var year = ((elem.start_date || elem.year || object.movie.release_date || object.movie.first_air_date || '') + '').slice(0, 4);
|
||||
if (year) info.push(year);
|
||||
if (elem.details) info.push(elem.details);
|
||||
var name = elem.title || elem.text;
|
||||
elem.title
|
||||
-2774
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
Lampa.Utils.putScriptAsync(['http://rc.bwa.ad/sisi.js?v23012026', 'https://cf.xsena.red?v14012026', 'http://pl.xsena.red?v14012026', 'http://nl.xsena.red?v14012026'], function () {});
|
||||
|
||||
/*if (Lampa.Platform.is('android'))
|
||||
Lampa.Utils.putScriptAsync(['https://immisterio.github.io/AdultJS/lampa.js'], function () {});
|
||||
*/
|
||||
})();
|
||||
-3016
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
//Не нужно переписывать весь этот код, не делайте так!
|
||||
//for Lampa.
|
||||
window.lampa_settings.torrents_use = true;
|
||||
window.lampa_settings.demo = false;
|
||||
window.lampa_settings.read_only = false;
|
||||
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -1,425 +0,0 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
var network = new Lampa.Reguest();
|
||||
|
||||
function getRandomKinopoiskTechKey() {
|
||||
const keys = ['8c8e1a50-6322-4135-8875-5d40a5420d86', 'f1d94351-2911-4485-b037-97817098724e', '8c8e1a50-6322-4135-8875-5d40a5420d86'];
|
||||
const randomIndex = Math.floor(Math.random() * keys.length);
|
||||
return keys[randomIndex];
|
||||
}
|
||||
|
||||
function calculateProgress(total, current) {
|
||||
if(total == current) {
|
||||
Lampa.Noty.show('Обновление списка фильмов Кинопоиска завершено (' + String(total) + ')');
|
||||
if(Lampa.Storage.get('kinopoisk_launched_before', false) == false) {
|
||||
Lampa.Storage.set('kinopoisk_launched_before', true);
|
||||
Lampa.Activity.push({
|
||||
url: '',
|
||||
title: 'Кинопоиск',
|
||||
component: 'kinopoisk',
|
||||
page: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processKinopoiskData(data) {
|
||||
// use cache
|
||||
if(data && data.data.userProfile && data.data.userProfile.userData && data.data.userProfile.userData.plannedToWatch) {
|
||||
var kinopoiskMovies = Lampa.Storage.get('kinopoisk_movies', []);
|
||||
var receivedMovies = data.data.userProfile.userData.plannedToWatch.movies.items;
|
||||
var receivedMoviesCount = receivedMovies.length;
|
||||
var moviesCount = data.data.userProfile.userData.plannedToWatch.movies.total;
|
||||
console.log('Kinopoisk', "Total planned to watch movies found: " + String(moviesCount));
|
||||
console.log('Kinopoisk', "Movies received count: " + String(receivedMoviesCount));
|
||||
if(receivedMoviesCount == 0) {
|
||||
Lampa.Noty.show('В списке "Буду смотреть" Кинопоиска нет фильмов');
|
||||
}
|
||||
const receivedMovieIds = new Set(receivedMovies.map(m => String(m.movie.id)));
|
||||
// filter out movies that are no longer present in receivedMovies
|
||||
kinopoiskMovies = kinopoiskMovies.filter(movie => receivedMovieIds.has(String(movie.kinopoisk_id)));
|
||||
Lampa.Storage.set('kinopoisk_movies', JSON.stringify(kinopoiskMovies));
|
||||
let processedItems = 1;
|
||||
receivedMovies.forEach(m => {
|
||||
const existsInLocalStorage = kinopoiskMovies.some(km => km.kinopoisk_id === String(m.movie.id));
|
||||
if (!existsInLocalStorage) {
|
||||
// get movie data
|
||||
var title = m.movie.title.localized || m.movie.title.original;
|
||||
console.log('Kinopoisk', 'Getting details for movie: ' + String(m.movie.id) + ', movie title: ' + title);
|
||||
// getting imdb id based on kinopoisk id
|
||||
// network.silent('https://kinopoiskapiunofficial.tech/api/v2.2/films/' + String(m.movie.id), function(data) {
|
||||
network.silent('https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&kp=' + String(m.movie.id), function(data) {
|
||||
if (data && data.data) {
|
||||
var movieIMDBid = data.data.id_imdb;
|
||||
var movieTMDBid = data.data.id_tmdb ? data.data.id_tmdb : null;
|
||||
var movieTitle = data.data.original_name ? data.data.original_name : data.data.name;
|
||||
if (data.data.category == 1) {
|
||||
var movieType = 'movie';
|
||||
} else { // 2
|
||||
var movieType = 'tv';
|
||||
}
|
||||
var movieYear = data.data.year;
|
||||
if (movieTMDBid) {
|
||||
console.log('Kinopoisk', 'TMDB movie id found: ' + String(movieTMDBid) + ' for kinopoisk id: ' + String(m.movie.id));
|
||||
var url = Lampa.Utils.protocol() + 'tmdb.'+ Lampa.Manifest.cub_domain +'/3/' + movieType + '/' + String(movieTMDBid) + '?api_key=4ef0d7355d9ffb5151e987764708ce96&language=ru';
|
||||
} else {
|
||||
if (movieType === 'movie') {
|
||||
console.log('Kinopoisk', 'No IMDB movie id found for kinopoisk id: ' + String(m.movie.id) + ', will search by movie title: ' + movieTitle);
|
||||
var url = Lampa.Utils.protocol() + 'tmdb.'+ Lampa.Manifest.cub_domain +'/3/search/movie?query=' + encodeURIComponent(movieTitle) + '&api_key=4ef0d7355d9ffb5151e987764708ce96&year=' + String(movieYear) + '&language=ru';
|
||||
} else { // TV_SERIES
|
||||
console.log('Kinopoisk', 'No IMDB movie id found for kinopoisk id: ' + String(m.movie.id) + ', will search by tv series title: ' + movieTitle);
|
||||
var url = Lampa.Utils.protocol() + 'tmdb.'+ Lampa.Manifest.cub_domain +'/3/search/tv?query=' + encodeURIComponent(movieTitle) + '&api_key=4ef0d7355d9ffb5151e987764708ce96&year=' + String(movieYear) + '&language=ru';
|
||||
}
|
||||
}
|
||||
// getting movie details
|
||||
network.silent(url, function(data) {
|
||||
if(data) {
|
||||
if (movieTMDBid) {
|
||||
var movieItem = data;
|
||||
} else {
|
||||
if (data.movie_results && data.movie_results[0]) {
|
||||
var movieItem = data.movie_results[0];
|
||||
} else if(data.tv_results && data.tv_results[0]) {
|
||||
var movieItem = data.tv_results[0];
|
||||
} else if(data.results && data.results[0]) {
|
||||
var movieItem = data.results[0];
|
||||
}
|
||||
}
|
||||
if(movieItem) {
|
||||
|
||||
// console.log('Kinopoisk', 'Movie details received: ' + JSON.stringify(movieItem));
|
||||
console.log('Kinopoisk', 'TMDB id found: ' + movieItem.id + ' for IMDB movie id: ' + movieIMDBid + ', kinopoisk id: ' + String(m.movie.id));
|
||||
|
||||
var movieDateStr = movieItem.release_date || movieItem.first_air_date; // film or tv series
|
||||
var movieDate = new Date(movieDateStr);
|
||||
|
||||
if (movieDate <= new Date()) {
|
||||
movieItem.kinopoisk_id = String(m.movie.id);
|
||||
movieItem.source = "tmdb";
|
||||
kinopoiskMovies = Lampa.Storage.get('kinopoisk_movies', []); // re-read data if another process modified it
|
||||
kinopoiskMovies.unshift(movieItem);
|
||||
Lampa.Storage.set('kinopoisk_movies', JSON.stringify(kinopoiskMovies));
|
||||
} else {
|
||||
console.log('Kinopoisk', 'Movie or TV with kinopoisk id ' + String(m.movie.id) + ' not released yet, release date:', movieDate);
|
||||
if (Lampa.Storage.get('kinopoisk_add_to_favorites', false)) { // add to favorites
|
||||
Lampa.Favorite.add('wath', movieItem, 100);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('Kinopoisk', 'No result found for ' + movieTitle + ', ' + movieYear, data);
|
||||
}
|
||||
} else {
|
||||
console.log('Kinopoisk', 'No movie found by IMDB id: ' + String(movieIMDBid));
|
||||
}
|
||||
calculateProgress(receivedMoviesCount, processedItems++);
|
||||
}, function(data) {
|
||||
console.log('Kinopoisk', 'apitmdb.cub.red error, data: ' + String(data));
|
||||
calculateProgress(receivedMoviesCount, processedItems++);
|
||||
});
|
||||
} else {
|
||||
console.log('Kinopoisk', 'No movie found for kinopoisk id: ' + String(m.movie.id) + ', movie: ' + title);
|
||||
calculateProgress(receivedMoviesCount, processedItems++);
|
||||
}
|
||||
}, function(data) {
|
||||
console.log('Kinopoisk', 'kinopoiskapiunofficial error, data: ' + String(data));
|
||||
calculateProgress(receivedMoviesCount, processedItems++);
|
||||
}, false, {
|
||||
type: 'get' //,
|
||||
// headers: {
|
||||
// 'X-API-KEY': getRandomKinopoiskTechKey()
|
||||
// }
|
||||
});
|
||||
} else {
|
||||
console.log('Kinopoisk', 'Reading data from local storage for movie: ' + String(m.movie.id))
|
||||
calculateProgress(receivedMoviesCount, processedItems++);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Lampa.Noty.show('Невозможно обработать данные, полученные от Кинопоиска');
|
||||
console.log('Kinopoisk', 'processKinopoiskData - ');
|
||||
console.log('Kinopoisk', data);
|
||||
}
|
||||
}
|
||||
|
||||
function getKinopoiskData() {
|
||||
console.log('Kinopoisk', 'Starting to get Kinopoisk data...');
|
||||
var oauth = Lampa.Storage.get('kinopoisk_access_token');
|
||||
// google script is used to act as CORS proxy
|
||||
network.silent('https://script.google.com/macros/s/AKfycbwQhxl9xQPv46uChWJ1UDg6BjSmefbSlTRUoSZz5f1rZDRvdhAGTi6RHyXwcSeyBtPr/exec?oauth=' + oauth, function(data) { // on success
|
||||
processKinopoiskData(data);
|
||||
}, function(data) { // on error
|
||||
console.log('Kinopoisk', 'Error, google script', data);
|
||||
});
|
||||
}
|
||||
|
||||
function full(params, oncomplete, onerror) {
|
||||
// https://github.com/yumata/lampa-source/blob/main/src/utils/reguest.js
|
||||
// https://github.com/yumata/lampa-source/blob/main/plugins/collections/api.js
|
||||
var oauth = Lampa.Storage.get('kinopoisk_access_token');
|
||||
if(oauth) {
|
||||
getKinopoiskData();
|
||||
}
|
||||
oncomplete({
|
||||
"secuses": true,
|
||||
"page": 1,
|
||||
"results": Lampa.Storage.get('kinopoisk_movies', [])
|
||||
});
|
||||
}
|
||||
|
||||
function clear() {
|
||||
network.clear();
|
||||
}
|
||||
var Api = {
|
||||
full: full,
|
||||
clear: clear
|
||||
};
|
||||
|
||||
function component(object) {
|
||||
var comp = new Lampa.InteractionCategory(object);
|
||||
comp.create = function() {
|
||||
Api.full(object, this.build.bind(this), this.empty.bind(this));
|
||||
};
|
||||
comp.nextPageReuest = function(object, resolve, reject) {
|
||||
Api.full(object, resolve.bind(comp), reject.bind(comp));
|
||||
};
|
||||
return comp;
|
||||
}
|
||||
// getting/refreshing oauth kinopoisk token
|
||||
function getToken(device_code, refresh) {
|
||||
var client_id = 'b8b9c7a09b79452094e12f6990009934';
|
||||
if(!refresh) {
|
||||
var token_data = {
|
||||
'grant_type': 'device_code',
|
||||
'code': device_code,
|
||||
'client_id': client_id,
|
||||
'client_secret': '0e7001e272944c05ae5a0df16e3ea8bd'
|
||||
}
|
||||
} else { // refresh token
|
||||
var token_data = {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': device_code, // pass refresh token as device_code
|
||||
'client_id': client_id,
|
||||
'client_secret': '0e7001e272944c05ae5a0df16e3ea8bd'
|
||||
}
|
||||
}
|
||||
network.silent('https://oauth.yandex.ru/token', function(data) { // on token success
|
||||
if(data.access_token) {
|
||||
Lampa.Storage.set('kinopoisk_access_token', data.access_token);
|
||||
Lampa.Storage.set('kinopoisk_refresh_token', data.refresh_token);
|
||||
Lampa.Storage.set('kinopoisk_token_expires', data.expires_in * 1000 + Date.now());
|
||||
Lampa.Modal.close();
|
||||
getUserEmail();
|
||||
getKinopoiskData();
|
||||
} else {
|
||||
Lampa.Noty.show('Не удалось получить token');
|
||||
console.log('Kinopoisk', 'Error during OAuth', data.error);
|
||||
}
|
||||
}, function(data) { // on token error
|
||||
Lampa.Noty.show(data.responseJSON.error_description);
|
||||
console.log('Kinopoisk', 'Token error', data);
|
||||
}, token_data);
|
||||
}
|
||||
// getting oauth user_code
|
||||
// https://yandex.ru/dev/id/doc/ru/codes/screen-code-oauth
|
||||
function getDeviceCode() {
|
||||
// generating unique device id
|
||||
const uuid4 = () => {
|
||||
const ho = (n, p) => n.toString(16).padStart(p, 0);
|
||||
const data = crypto.getRandomValues(new Uint8Array(16));
|
||||
data[6] = (data[6] & 0xf) | 0x40;
|
||||
data[8] = (data[8] & 0x3f) | 0x80;
|
||||
const view = new DataView(data.buffer);
|
||||
return `${ho(view.getUint32(0), 8)}${ho(view.getUint16(4), 4)}${ho(view.getUint16(6), 4)}${ho(view.getUint16(8), 4)}${ho(view.getUint32(10), 8)}${ho(view.getUint16(14), 4)}`; /// Compile the canonical textual form from the array data
|
||||
};
|
||||
Lampa.Storage.set('kinopoisk_deviceid', uuid4());
|
||||
var client_id = 'b8b9c7a09b79452094e12f6990009934';
|
||||
var device_code_data = {
|
||||
'client_id': client_id,
|
||||
'device_id': Lampa.Storage.get('kinopoisk_deviceid', '')
|
||||
}
|
||||
network.silent('https://oauth.yandex.ru/device/code', function(data) { // on device code success
|
||||
if(data.user_code && data.device_code) {
|
||||
// Lampa.Utils.copyTextToClipboard(data.user_code, ()=>{});
|
||||
// ask user to authorize
|
||||
let modal = $('<div><div class="about">Перейдите по ссылке https://ya.ru/device на любом устройстве и введите код<br><br><b>' + data.user_code + '</b><br><br></div><br><div class="broadcast__device selector" style="textalign: center">Готово</div></div>')
|
||||
Lampa.Modal.open({
|
||||
title: 'Авторизация',
|
||||
html: modal,
|
||||
align: 'center',
|
||||
onBack: () => {
|
||||
Lampa.Modal.close()
|
||||
},
|
||||
onSelect: () => { // on button click
|
||||
getToken(data.device_code, false);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Lampa.Noty.show('Не удалось получить user_code');
|
||||
console.log('Kinopoisk', 'Failed to get user_code', data.error);
|
||||
}
|
||||
}, function(data) { // on device code error
|
||||
Lampa.Noty.show(data.responseJSON.error_description);
|
||||
console.log('Kinopoisk', 'Failed to get device code', data);
|
||||
}, device_code_data);
|
||||
}
|
||||
|
||||
function getUserEmail() {
|
||||
network.silent('https://login.yandex.ru/info?format=json', function(data) {
|
||||
if (data.default_email) {
|
||||
Lampa.Storage.set('kinopoisk_email', data.default_email);
|
||||
|
||||
$('div[data-name="kinopoisk_auth"]').find('.settings-param__name').text(data.default_email); // NOT WORKING?
|
||||
} else {
|
||||
Lampa.Noty.show('Не удалось получить email пользователя');
|
||||
console.log('Kinopoisk', 'Failed to get user email', data.error);
|
||||
}
|
||||
}, function(data) { // on device code error
|
||||
Lampa.Noty.show(data.responseText);
|
||||
console.log('Kinopoisk', 'Failed to get user email', data);
|
||||
}, false, {
|
||||
type: 'get',
|
||||
headers: {
|
||||
'Authorization': 'OAuth ' + Lampa.Storage.get('kinopoisk_access_token')
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function startPlugin() {
|
||||
var manifest = {
|
||||
type: 'video',
|
||||
version: '0.4.0',
|
||||
name: 'Кинопоиск',
|
||||
description: '',
|
||||
component: 'kinopoisk'
|
||||
};
|
||||
Lampa.Manifest.plugins = manifest;
|
||||
Lampa.Component.add('kinopoisk', component);
|
||||
if(Lampa.Storage.get('kinopoisk_access_token', '') !== '' && Lampa.Storage.get('kinopoisk_token_expires', 0) < Date.now()) { // refresh token needed
|
||||
console.log('Kinopoisk', 'Token should be refreshed')
|
||||
getToken(Lampa.Storage.get('kinopoisk_refresh_token', ''), true);
|
||||
}
|
||||
|
||||
function add() {
|
||||
var button = $("<li class=\"menu__item selector\">\n <div class=\"menu__ico\">\n <svg width=\"239\" height=\"239\" viewBox=\"0 0 239 239\" fill=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\" xml:space=\"preserve\"><path fill=\"currentColor\" d=\"M215 121.415l-99.297-6.644 90.943 36.334a106.416 106.416 0 0 0 8.354-29.69z\" /><path fill=\"currentColor\" d=\"M194.608 171.609C174.933 197.942 143.441 215 107.948 215 48.33 215 0 166.871 0 107.5 0 48.13 48.33 0 107.948 0c35.559 0 67.102 17.122 86.77 43.539l-90.181 48.07L162.57 32.25h-32.169L90.892 86.862V32.25H64.77v150.5h26.123v-54.524l39.509 54.524h32.169l-56.526-57.493 88.564 46.352z\" /><path d=\"M206.646 63.895l-90.308 36.076L215 93.583a106.396 106.396 0 0 0-8.354-29.688z\" fill=\"currentColor\"/></svg>\n </div>\n <div class=\"menu__text\">".concat(manifest.name, "</div>\n </li>"));
|
||||
button.on('hover:enter', function() {
|
||||
if(Lampa.Storage.get('kinopoisk_access_token', '') == '') { // initial authorization needed
|
||||
getDeviceCode();
|
||||
}
|
||||
Lampa.Activity.push({
|
||||
url: '',
|
||||
title: manifest.name,
|
||||
component: 'kinopoisk',
|
||||
page: 1
|
||||
});
|
||||
});
|
||||
$('.menu .menu__list').eq(0).append(button);
|
||||
// $('.head__actions').eq(0).append(button);
|
||||
}
|
||||
if(window.appready) add();
|
||||
else {
|
||||
Lampa.Listener.follow('app', function(e) {
|
||||
if(e.type == 'ready') add();
|
||||
});
|
||||
}
|
||||
// SETTINGS
|
||||
if(!window.lampa_settings.kinopoisk) { // re-use kinopoisk_ratings element, if exists
|
||||
Lampa.SettingsApi.addComponent({
|
||||
component: 'kinopoisk',
|
||||
icon: '<svg width=\"239\" height=\"239\" viewBox=\"0 0 239 239\" fill=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\" xml:space=\"preserve\"><path fill=\"currentColor\" d=\"M215 121.415l-99.297-6.644 90.943 36.334a106.416 106.416 0 0 0 8.354-29.69z\" /><path fill=\"currentColor\" d=\"M194.608 171.609C174.933 197.942 143.441 215 107.948 215 48.33 215 0 166.871 0 107.5 0 48.13 48.33 0 107.948 0c35.559 0 67.102 17.122 86.77 43.539l-90.181 48.07L162.57 32.25h-32.169L90.892 86.862V32.25H64.77v150.5h26.123v-54.524l39.509 54.524h32.169l-56.526-57.493 88.564 46.352z\" /><path d=\"M206.646 63.895l-90.308 36.076L215 93.583a106.396 106.396 0 0 0-8.354-29.688z\" fill=\"currentColor\"/></svg>',
|
||||
name: 'Кинопоиск'
|
||||
});
|
||||
}
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'kinopoisk',
|
||||
param: {
|
||||
type: 'title'
|
||||
},
|
||||
field: {
|
||||
name: 'Аккаунт',
|
||||
}
|
||||
})
|
||||
var kinopoisk_email = Lampa.Storage.get('kinopoisk_email', false);
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'kinopoisk',
|
||||
param: {
|
||||
type: 'button',
|
||||
name: 'kinopoisk_auth'
|
||||
},
|
||||
field: {
|
||||
name: kinopoisk_email ? kinopoisk_email : 'Авторизоваться',
|
||||
},
|
||||
onChange: () => {
|
||||
if (Lampa.Storage.get('kinopoisk_email', false)) { // user authorized
|
||||
Lampa.Select.show({
|
||||
title: 'Выйти из аккаунта',
|
||||
items: [{
|
||||
title: 'Да',
|
||||
confirm: true
|
||||
}, {
|
||||
title: 'Нет'
|
||||
}],
|
||||
onSelect: (a) => {
|
||||
if(a.confirm) {
|
||||
Lampa.Storage.set('kinopoisk_email', '');
|
||||
Lampa.Storage.set('kinopoisk_access_token', '');
|
||||
Lampa.Storage.set('kinopoisk_refresh_token', '');
|
||||
// Lampa.Storage.set('kinopoisk_movies', []);
|
||||
Lampa.Storage.set('kinopoisk_token_expires', 0);
|
||||
$('div[data-name="kinopoisk_auth"]').find('.settings-param__name').text('Авторизоваться');
|
||||
}
|
||||
|
||||
Lampa.Controller.toggle('settings_component');
|
||||
},
|
||||
onBack: ()=>{
|
||||
Lampa.Controller.toggle('settings_component');
|
||||
},
|
||||
})
|
||||
} else { // user not authorized
|
||||
Lampa.Controller.toContent(); // hide settings menu
|
||||
getDeviceCode(); // start auth process
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'kinopoisk',
|
||||
param: {
|
||||
type: 'title'
|
||||
},
|
||||
field: {
|
||||
name: 'Список Буду смотреть',
|
||||
}
|
||||
})
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'kinopoisk',
|
||||
param: {
|
||||
name: 'kinopoisk_add_to_favorites',
|
||||
type: 'trigger',
|
||||
default: false
|
||||
},
|
||||
field: {
|
||||
name: 'Добавлять в Избранное',
|
||||
description: 'Будущие, еще не вышедшие релизы добавляются в список Позже'
|
||||
}
|
||||
})
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'kinopoisk',
|
||||
param: {
|
||||
type: 'button',
|
||||
name: 'kinopoisk_delete_cache'
|
||||
},
|
||||
field: {
|
||||
name: 'Очистить кэш фильмов',
|
||||
description: 'Необходимо при возникновении проблем'
|
||||
},
|
||||
onChange: () => {
|
||||
Lampa.Storage.set('kinopoisk_movies', []);
|
||||
Lampa.Noty.show('Кэш Кинопоиска очищен');
|
||||
}
|
||||
});
|
||||
}
|
||||
if(!window.kinopoisk_ready) startPlugin();
|
||||
})();
|
||||
-1966
File diff suppressed because it is too large
Load Diff
-15735
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
(function(){'use strict';function startPlugin(){window.logoplugin=!0;Lampa.Listener.follow('full',function(e){if(e.type=='complite'&&Lampa.Storage.get('logo_glav')!='1'){var data=e.data.movie;var type=data.name?'tv':'movie';if(data.id!='')var url=Lampa.TMDB.api(type+'/'+data.id+'/images?api_key='+Lampa.TMDB.key()+'&language='+Lampa.Storage.get('language'));$.get(url,function(data){if(data.logos&&data.logos[0]){var logo=data.logos[0].file_path;if(logo!='')e.object.activity.render().find('.full-start-new__title').html('<img style="margin-top:5px;max-height:125px;" src="'+Lampa.TMDB.image('/t/p/w300'+logo.replace('.svg','.png'))+'"/>')}})}})}
|
||||
Lampa.SettingsApi.addParam({component:'interface',param:{name:'logo_glav',type:'select',values:{1:'Скрыть',0:'Отображать',},default:'0',},field:{name:'Логотипы вместо названий',description:'Отображает логотипы фильмов вместо текста',}});if(!window.logoplugin)startPlugin()})()
|
||||
@@ -1,312 +0,0 @@
|
||||
|
||||
if(!window.loaded_modss) {
|
||||
//show
|
||||
Date.now||(Date.now=function(){return(new Date.getTime())}),function(){"use strict";for(var t=["webkit","moz"],e=0;e<t.length&&!window.requestAnimationFrame;++e){var i=t[e];window.requestAnimationFrame=window[i+"RequestAnimationFrame"],window.cancelAnimationFrame=window[i+"CancelAnimationFrame"]||window[i+"CancelRequestAnimationFrame"]}if(/iP(ad|hone|od).*OS 6/.test(window.navigator.userAgent)||!window.requestAnimationFrame||!window.cancelAnimationFrame){var s=0;window.requestAnimationFrame=function(t){var e=Date.now(),i=Math.max(s+16,e);return setTimeout(function(){t(s=i)},i-e)},window.cancelAnimationFrame=clearTimeout}}(),function(t){t.snowfall=function(e,i){function s(s,n,a,r){this.x=s,this.y=n,this.size=a,this.speed=r,this.step=0,this.stepSize=h(1,10)/100,i.collection&&(this.target=m[h(0,m.length-1)]);var p=null;i.image?(p=document.createElement("img"),p.src=i.image):(p=document.createElement("div"),t(p).css({background:i.flakeColor})),t(p).attr({"class":"snowfall-flakes"}).css({width:this.size,height:this.size,position:i.flakePosition,top:this.y,left:this.x,fontSize:0,zIndex:i.flakeIndex}),t(e).get(0).tagName===t(document).get(0).tagName?(t("body").append(t(p)),e=t("body")):t(e).append(t(p)),this.element=p,this.update=function(){if(this.y+=this.speed,this.y>l-(this.size+6)&&this.reset(),this.element.style.top=this.y+"px",this.element.style.left=this.x+"px",this.step+=this.stepSize,y===!1?this.x+=Math.cos(this.step):this.x+=y+Math.cos(this.step),i.collection&&this.x>this.target.x&&this.x<this.target.width+this.target.x&&this.y>this.target.y&&this.y<this.target.height+this.target.y){var t=this.target.element.getContext("2d"),e=this.x-this.target.x,s=this.y-this.target.y,n=this.target.colData;if(void 0!==n[parseInt(e)][parseInt(s+this.speed+this.size)]||s+this.speed+this.size>this.target.height)if(s+this.speed+this.size>this.target.height){for(;s+this.speed+this.size>this.target.height&&this.speed>0;)this.speed*=.5;t.fillStyle=o.flakeColor,void 0==n[parseInt(e)][parseInt(s+this.speed+this.size)]?(n[parseInt(e)][parseInt(s+this.speed+this.size)]=1,t.fillRect(e,s+this.speed+this.size,this.size,this.size)):(n[parseInt(e)][parseInt(s+this.speed)]=1,t.fillRect(e,s+this.speed,this.size,this.size)),this.reset()}else this.speed=1,this.stepSize=0,parseInt(e)+1<this.target.width&&void 0==n[parseInt(e)+1][parseInt(s)+1]?this.x++:parseInt(e)-1>0&&void 0==n[parseInt(e)-1][parseInt(s)+1]?this.x--:(t.fillStyle=o.flakeColor,t.fillRect(e,s,this.size,this.size),n[parseInt(e)][parseInt(s)]=1,this.reset())}(this.x+this.size>d-c||this.x<c)&&this.reset()},this.reset=function(){this.y=0,this.x=h(c,d-c),this.stepSize=h(1,10)/100,this.size=h(100*i.minSize,100*i.maxSize)/100,this.element.style.width=this.size+"px",this.element.style.height=this.size+"px",this.speed=h(i.minSpeed,i.maxSpeed)}}function n(){for(r=0;r<a.length;r+=1)a[r].update();p=requestAnimationFrame(function(){n()})}var a=[],o={flakeCount:35,flakeColor:"#ffffff",flakePosition:"absolute",flakeIndex:999999,minSize:1,maxSize:2,minSpeed:1,maxSpeed:5,round:!1,shadow:!1,collection:!1,collectionHeight:40,deviceorientation:!1},i=t.extend(o,i),h=function(t,e){return Math.round(t+Math.random()*(e-t))};t(e).data("snowfall",this);var r=0,l=t(e).height(),d=t(e).width(),c=0,p=0;if(i.collection!==!1){var f=document.createElement("canvas");if(f.getContext&&f.getContext("2d"))for(var m=[],w=t(i.collection),g=i.collectionHeight,r=0;r<w.length;r++){var u=w[r].getBoundingClientRect(),x=t("<canvas/>",{"class":"snowfall-canvas"}),z=[];if(u.top-g>0){t("body").append(x),x.css({position:i.flakePosition,left:u.left+"px",top:u.top-g+"px"}).prop({width:u.width,height:g});for(var v=0;v<u.width;v++)z[v]=[];m.push({element:x.get(0),x:u.left,y:u.top-g,width:u.width,height:g,colData:z})}}else i.collection=!1}for(t(e).get(0).tagName===t(document).get(0).tagName&&(c=25),t(window).bind("resize",function(){l=t(e)[0].clientHeight,d=t(e)[0].offsetWidth}),r=0;r<i.flakeCount;r+=1)a.push(new s(h(c,d-c),h(0,l),h(100*i.minSize,100*i.maxSize)/100,h(i.minSpeed,i.maxSpeed)));i.round&&t(".snowfall-flakes").css({"-moz-border-radius":i.maxSize,"-webkit-border-radius":i.maxSize,"border-radius":i.maxSize}),i.shadow&&t(".snowfall-flakes").css({"-moz-box-shadow":"1px 1px 1px #555","-webkit-box-shadow":"1px 1px 1px #555","box-shadow":"1px 1px 1px #555"});var y=!1;i.deviceorientation&&t(window).bind("deviceorientation",function(t){y=.1*t.originalEvent.gamma}),n(),this.clear=function(){t(".snowfall-canvas").remove(),t(e).children(".snowfall-flakes").remove(),cancelAnimationFrame(p)}},t.fn.snowfall=function(e){return"object"==typeof e||void 0==e?this.each(function(i){new t.snowfall(this,e)}):"string"==typeof e?this.each(function(e){var i=t(this).data("snowfall");i&&i.clear()}):void 0}}(jQuery);
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
||||
try {
|
||||
if (!Array.isArray(window.__modssConsoleBuf)) window.__modssConsoleBuf = [];
|
||||
|
||||
window.__modssConsoleOnCall = function (method, args) {
|
||||
try {
|
||||
var buf = window.__modssConsoleBuf;
|
||||
if (!Array.isArray(buf)) { buf = []; window.__modssConsoleBuf = buf; }
|
||||
var list = args && typeof args.length === 'number' ? Array.prototype.slice.call(args) : [];
|
||||
var line = list.map(function (a) {
|
||||
if (a == null) return String(a);
|
||||
if (typeof a === 'string' || typeof a === 'number' || typeof a === 'boolean') return String(a);
|
||||
try { return JSON.stringify(a); } catch (eJ) { return String(a); }
|
||||
}).join(' ');
|
||||
buf.push({ t: Date.now(), m: method || 'log', line: line });
|
||||
while (buf.length > 800) buf.shift();
|
||||
} catch (eCap) { }
|
||||
};
|
||||
|
||||
window.__modssEnsureConsoleCapture = function () {
|
||||
try {
|
||||
if (!Array.isArray(window.__modssConsoleBuf)) window.__modssConsoleBuf = [];
|
||||
var methods = ['log', 'info', 'warn', 'error', 'debug'];
|
||||
for (var i = 0; i < methods.length; i++) {
|
||||
(function (method) {
|
||||
/** try на каждый метод: на части WebView console.* только для чтения —
|
||||
* раньше падение на первом методе снимало перехват целиком. */
|
||||
try {
|
||||
var cur = console[method];
|
||||
if (typeof cur !== 'function' || cur.__modssCap) return;
|
||||
var orig = cur;
|
||||
var wrapped = function () {
|
||||
try { if (typeof window.__modssConsoleOnCall === 'function') window.__modssConsoleOnCall(method, arguments); } catch (e1) { }
|
||||
try { return orig.apply(console, arguments); } catch (e2) { }
|
||||
};
|
||||
wrapped.__modssCap = 1;
|
||||
console[method] = wrapped;
|
||||
if (console[method] !== wrapped) {
|
||||
Object.defineProperty(console, method, { value: wrapped, configurable: true, writable: true });
|
||||
}
|
||||
} catch (eM) {
|
||||
window.__modssConsoleCaptureError = String((eM && eM.message) || eM).slice(0, 200);
|
||||
}
|
||||
})(methods[i]);
|
||||
}
|
||||
window.__modssConsoleCapture = !!(typeof console.log === 'function' && console.log.__modssCap);
|
||||
} catch (eEns) {
|
||||
window.__modssConsoleCaptureError = String((eEns && eEns.message) || eEns).slice(0, 200);
|
||||
}
|
||||
};
|
||||
|
||||
window.__modssEnsureConsoleCapture();
|
||||
setInterval(window.__modssEnsureConsoleCapture, 1000);
|
||||
} catch (eBootCap) { }
|
||||
|
||||
|
||||
var pluginSetup = {
|
||||
urlKey: function (url) {
|
||||
return String(url == null ? '' : url).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
},
|
||||
|
||||
accountPluginKeys: function (done) {
|
||||
Lampa.Account.Api.plugins(function (items) {
|
||||
var keys = [];
|
||||
|
||||
for (var i = 0; i < (items || []).length; i++) {
|
||||
var key = Number(items[i].status) === 1 ? pluginSetup.urlKey(items[i].url) : '';
|
||||
|
||||
if (key) keys.push(key);
|
||||
}
|
||||
|
||||
done(keys);
|
||||
});
|
||||
},
|
||||
|
||||
detectShell: function () {
|
||||
var ua = String(navigator.userAgent || '').toLowerCase();
|
||||
|
||||
if (typeof webapis !== 'undefined' && typeof tizen !== 'undefined') return 'tizen';
|
||||
if (typeof webOS !== 'undefined' && webOS.platform && webOS.platform.tv === true) return 'webos';
|
||||
if (window.TVXHost || window.TVXManager) return 'msx';
|
||||
if (ua.indexOf('tizen') !== -1 || ua.indexOf('webos') !== -1 || ua.indexOf('web0s') !== -1) return 'msx';
|
||||
if (Lampa.Platform.is('apple_tv')) return 'apple_tv';
|
||||
if (Lampa.Utils.isPWA && Lampa.Utils.isPWA()) return 'pwa';
|
||||
|
||||
return '';
|
||||
},
|
||||
|
||||
baseDeviceId: function (value) {
|
||||
return String(value == null ? '' : value).trim().replace(/_(?:pwa|msx|apple_tv|tizen|webos)$/, '');
|
||||
},
|
||||
|
||||
taggedDeviceId: function (value) {
|
||||
var base = pluginSetup.baseDeviceId(value);
|
||||
var shell = pluginSetup.detectShell();
|
||||
|
||||
return base && shell ? base + '_' + shell : base;
|
||||
},
|
||||
|
||||
saveDeviceId: function (value) {
|
||||
var deviceId = pluginSetup.taggedDeviceId(value);
|
||||
|
||||
if (deviceId && deviceId !== Lampa.Storage.get('modss_device_id', '')) {
|
||||
Lampa.Storage.set('modss_device_id', deviceId);
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
},
|
||||
|
||||
resolveDeviceId: function (issuedId) {
|
||||
var cached = Lampa.Storage.get('modss_device_id', '');
|
||||
|
||||
return cached ? pluginSetup.saveDeviceId(cached) : pluginSetup.taggedDeviceId(issuedId);
|
||||
},
|
||||
|
||||
syncDeviceId: function (issuedId, onReady) {
|
||||
var attempts = 0;
|
||||
|
||||
var tick = function () {
|
||||
var cached = Lampa.Storage.get('modss_device_id', '');
|
||||
|
||||
if ((!cached || !pluginSetup.detectShell()) && attempts++ < 15) return setTimeout(tick, 100);
|
||||
|
||||
if (typeof onReady === 'function') onReady(pluginSetup.saveDeviceId(cached || issuedId));
|
||||
};
|
||||
|
||||
tick();
|
||||
},
|
||||
|
||||
shell: function () {
|
||||
return pluginSetup.detectShell() || Lampa.Platform.get();
|
||||
},
|
||||
|
||||
deviceKind: function (base) {
|
||||
var kind = String(base || 'other');
|
||||
var shell = pluginSetup.shell();
|
||||
if (!shell || kind.indexOf('_') !== -1) return kind;
|
||||
return kind + '_' + shell;
|
||||
},
|
||||
|
||||
logDevice: function (base, issuedId, onChange) {
|
||||
pluginSetup.syncDeviceId(issuedId, function (deviceId) {
|
||||
if (deviceId && typeof onChange === 'function') onChange(deviceId);
|
||||
console.log('Modss', 'device', '[' + pluginSetup.deviceKind(base) + ']');
|
||||
});
|
||||
},
|
||||
|
||||
cleanPluginList: function (liveUrls, deadUrls) {
|
||||
pluginSetup.accountPluginKeys(function (accountKeys) {
|
||||
pluginSetup.applyPluginList(liveUrls, deadUrls, accountKeys);
|
||||
});
|
||||
},
|
||||
|
||||
applyPluginList: function (liveUrls, deadUrls, accountKeys) {
|
||||
try {
|
||||
var plugins = Lampa.Plugins.get();
|
||||
var accountHasLive = false;
|
||||
var remove = [];
|
||||
var live = [];
|
||||
var changed = false;
|
||||
|
||||
for (var a = 0; a < liveUrls.length; a++) {
|
||||
if (accountKeys.indexOf(liveUrls[a]) !== -1) accountHasLive = true;
|
||||
}
|
||||
|
||||
for (var i = 0; i < plugins.length; i++) {
|
||||
var plugin = plugins[i];
|
||||
var key = pluginSetup.urlKey(plugin.url);
|
||||
|
||||
if (deadUrls.indexOf(key) !== -1) {
|
||||
remove.push({ plugin: plugin, reason: 'dead' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (liveUrls.indexOf(key) === -1) continue;
|
||||
|
||||
if (accountHasLive) remove.push({ plugin: plugin, reason: 'cub' });
|
||||
else live.push({ plugin: plugin, key: key });
|
||||
}
|
||||
|
||||
var keep = null;
|
||||
|
||||
for (var k = 0; k < live.length; k++) {
|
||||
if (keep === null || liveUrls.indexOf(live[k].key) < liveUrls.indexOf(keep.key)) keep = live[k];
|
||||
}
|
||||
|
||||
for (var d = 0; d < live.length; d++) {
|
||||
if (live[d] !== keep) remove.push({ plugin: live[d].plugin, reason: 'dup' });
|
||||
}
|
||||
|
||||
if (keep && String(keep.plugin.descr || '').trim() !== '') {
|
||||
keep.plugin.descr = '';
|
||||
keep.plugin.name = "MODS's";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (var r = 0; r < remove.length; r++) {
|
||||
if (!keep && !accountHasLive && r === 0) {
|
||||
keep = { plugin: remove[r].plugin, key: liveUrls[0] };
|
||||
keep.plugin.url = (window.location.protocol === 'https:' ? 'https://' : 'http://') + liveUrls[0];
|
||||
changed = true;
|
||||
console.log('Modss', 'plugin url', 'moved to ' + keep.plugin.url);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('Modss', 'plugin url', 'removed [' + remove[r].reason + '] ' + remove[r].plugin.url);
|
||||
Lampa.Plugins.remove(remove[r].plugin);
|
||||
}
|
||||
|
||||
if (changed) Lampa.Plugins.save();
|
||||
} catch (eClean) {
|
||||
console.error('Modss', 'plugin url', 'clean error', eClean && eClean.message ? eClean.message : String(eClean));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
var Protocol = function Protocol() {
|
||||
return window.location.protocol == 'https:' ? 'https://' : 'http://';
|
||||
}
|
||||
var TRASH_R = ['$$$####!!!!!!!', '^^^^^^##@', '@!^^!@#@@$$$$$', '^^#@@!!@#!$', '@#!@@@##$$@@'];
|
||||
var version_modss = '3.4.4', API = Protocol() + 'api.lampa.stream/', type = '', jackets = {}, cards, ping_auth, manifest, menu_list = [], vip = false, leftVipD = '', user_id = '', uid = '9f1c933cf85ae1ac7df7f332c_9d8ef5d9e88eb7c443bd2751', IP, logged = undefined, cashe = encodeURIComponent(Lampa.Base64.encode(window.location.origin));
|
||||
|
||||
var device_id = pluginSetup.resolveDeviceId('b546aae137ce4d0421cccb77ff745878');
|
||||
|
||||
var urls = [
|
||||
Protocol() + 'lampa.stream/modss',
|
||||
Protocol() + 'modss.tv'
|
||||
];
|
||||
|
||||
console.log('Modss', 'protocol:', Protocol());
|
||||
console.log('Modss', 'origin', window.location.origin);
|
||||
console.log('Modss', 'plugin', '[VERSION] ' + version_modss);
|
||||
pluginSetup.logDevice("other", device_id, function (next) {
|
||||
device_id = next;
|
||||
if (requestData) requestData.device_id = next;
|
||||
});
|
||||
console.log('Modss', 'init', 'LOADED - RU [85.234.8.237] - [GET] ' + urls[0]);
|
||||
window.loaded_modss = true;
|
||||
|
||||
var network = new Lampa.Reguest();
|
||||
var currentUrlIndex = 0;
|
||||
var loadErrors = [];
|
||||
var requestData = {
|
||||
user_id: user_id,
|
||||
uid: uid,
|
||||
ips: '85.234.8.237',
|
||||
cas: cashe,
|
||||
cache: false,
|
||||
id: '',
|
||||
or: 'dW5kZWZpbmVk',
|
||||
auth: logged,
|
||||
device_id: device_id
|
||||
};
|
||||
|
||||
function tryNextUrl() {
|
||||
if (currentUrlIndex >= urls.length) {
|
||||
var statusCodes = loadErrors.map(function(e) { return String(e.status != null ? e.status : '?'); }).join(', ');
|
||||
console.log('Modss', 'init', 'RU [85.234.8.237] - все URL недоступны. Статусы ответов:', statusCodes, loadErrors);
|
||||
Lampa.Noty.show('RU [85.234.8.237] - MODSs ОШИБКА ЗАГРУЗКИ ПЛАГИНА -> все серверы недоступны.<br>Статусы: ' + (statusCodes || '—') + '<br>Lampa: ' + window.location.origin, {time: 10000});
|
||||
window.loaded_modss = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var currentUrl = urls[currentUrlIndex];
|
||||
var attemptNumber = currentUrlIndex + 1;
|
||||
console.log('Modss', 'init', '[POST] Попытка ' + attemptNumber + '/' + urls.length + ' - загрузки:', currentUrl);
|
||||
if(attemptNumber > 1) Lampa.Noty.show('Modss - init [POST] Попытка ' + attemptNumber + '/' + urls.length + ' - загрузки: ' + currentUrl, {time: 6000});
|
||||
|
||||
network.timeout(10000);
|
||||
network.silent(currentUrl, function(json) {
|
||||
try {
|
||||
console.log('Modss', 'push', 'START - ' + currentUrl);
|
||||
eval(json + '//# sourceURL='+window.location.origin+'/plugin_modss.js');
|
||||
window.loaded_modss = true;
|
||||
} catch (error) {
|
||||
Lampa.Noty.show('MODSs ОШИБКА ЗАГРУЗКИ -> ' + error.message);
|
||||
console.log('Modss', 'init', 'ERROR - ' + currentUrl, error.message);
|
||||
console.error('Ошибка выполнения кода:', error);
|
||||
currentUrlIndex++;
|
||||
tryNextUrl();
|
||||
}
|
||||
}, function(a, c) {
|
||||
var errMsg = network.errorDecode(a, c);
|
||||
var statusNum = typeof a === 'number' ? a : (a && (a.status !== undefined ? a.status : (a.statusCode !== undefined ? a.statusCode : null)));
|
||||
if (statusNum === undefined || statusNum === null) statusNum = '?';
|
||||
loadErrors.push({ url: currentUrl, status: statusNum, message: errMsg || '' });
|
||||
console.log('Modss', 'init', 'RU [85.234.8.237] - load get plugin error - [POST] ' + currentUrl, 'status:', statusNum, errMsg);
|
||||
if (statusNum === 444 || statusNum === 403) {
|
||||
console.error('Modss', 'init', 'ERROR - BLOCKED (status ' + statusNum + ')');
|
||||
Lampa.Noty.show('MODSs BLOCKED — ' + (errMsg || statusNum), {time: 8000});
|
||||
window.loaded_modss = false;
|
||||
return;
|
||||
}
|
||||
currentUrlIndex++;
|
||||
tryNextUrl();
|
||||
}, requestData, {
|
||||
dataType: 'text'
|
||||
});
|
||||
}
|
||||
|
||||
tryNextUrl();
|
||||
})();
|
||||
}
|
||||
-3507
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
Lampa.Storage.set('is_true_mobile', 'false');
|
||||
})();
|
||||
-14948
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,536 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function translate() {
|
||||
Lampa.Lang.add({
|
||||
lme_parser: {
|
||||
ru: 'Каталог парсеров',
|
||||
en: 'Parsers catalog',
|
||||
uk: 'Каталог парсерів',
|
||||
zh: '解析器目录' // Chinese translation
|
||||
},
|
||||
lme_parser_description: {
|
||||
ru: 'Нажмите для выбора парсера из ',
|
||||
en: 'Click to select a parser from the ',
|
||||
uk: 'Натисніть для вибору парсера з ',
|
||||
zh: '单击以从可用的 '
|
||||
},
|
||||
lme_parser_current: {
|
||||
ru: 'Текущий выбор:',
|
||||
en: 'Current selection:',
|
||||
uk: 'Поточний вибір:',
|
||||
zh: '当前选择:'
|
||||
},
|
||||
lme_parser_selected: {
|
||||
ru: 'Выбрано',
|
||||
en: 'Selected',
|
||||
uk: 'Обрано',
|
||||
zh: '已选择'
|
||||
},
|
||||
lme_parser_refresh: {
|
||||
ru: 'Обновить проверку',
|
||||
en: 'Refresh check',
|
||||
uk: 'Оновити перевірку',
|
||||
zh: '刷新检测'
|
||||
},
|
||||
lme_parser_none: {
|
||||
ru: 'Не выбран',
|
||||
en: 'Not selected',
|
||||
uk: 'Не вибрано',
|
||||
zh: '未选择'
|
||||
},
|
||||
lme_parser_none_description: {
|
||||
ru: 'Без активного парсера',
|
||||
en: 'No active parser',
|
||||
uk: 'Без активного парсера',
|
||||
zh: '未启用解析器'
|
||||
},
|
||||
lme_parser_health: {
|
||||
ru: 'Индикация состояния парсеров',
|
||||
en: 'Parser health indicator',
|
||||
uk: 'Індикація стану парсерів',
|
||||
zh: '解析器状态指示'
|
||||
},
|
||||
lme_parser_status_ok: {
|
||||
ru: 'Доступен',
|
||||
en: 'Available',
|
||||
uk: 'Доступний',
|
||||
zh: '可用'
|
||||
},
|
||||
lme_parser_status_auth: {
|
||||
ru: 'Ошибка ключа',
|
||||
en: 'Auth error',
|
||||
uk: 'Помилка ключа',
|
||||
zh: '密钥错误'
|
||||
},
|
||||
lme_parser_status_network: {
|
||||
ru: 'Недоступен',
|
||||
en: 'Unavailable',
|
||||
uk: 'Недоступний',
|
||||
zh: '不可用'
|
||||
},
|
||||
lme_parser_status_unknown: {
|
||||
ru: 'Не проверен',
|
||||
en: 'Unchecked',
|
||||
uk: 'Не перевірено',
|
||||
zh: '未检查'
|
||||
},
|
||||
lme_parser_status_checking: {
|
||||
ru: 'Проверка...',
|
||||
en: 'Checking...',
|
||||
uk: 'Перевірка...',
|
||||
zh: '检查中...'
|
||||
},
|
||||
lme_pubtorr: {
|
||||
ru: 'Каталог TorrServer',
|
||||
en: 'TorrServer catalog',
|
||||
uk: 'Каталог TorrServer',
|
||||
zh: '解析器目录' // Chinese translation
|
||||
},
|
||||
lme_pubtorr_description: {
|
||||
ru: 'Бесплатные серверы от проекта LME',
|
||||
en: 'Free servers from the LME project',
|
||||
uk: 'Безкоштовні сервери від проєкту LME',
|
||||
zh: '来自 LME 项目的免费服务器 '
|
||||
},
|
||||
lme_pubtorr_firstrun: {
|
||||
"ru": "Привет! Ты установил плагин LME PubTorr, учти что если стоит Mods's то в разделе парсеров будет ошибка, которая не влияет на работу. Хочешь избавиться - оставь или LME PubTorr или Mods's.",
|
||||
"en": "Hello! You have installed the LME PubTorr plugin. Note that if Mods's is enabled, there will be an error in the parsers section that does not affect functionality. If you want to get rid of it, keep either LME PubTorr or Mods's.",
|
||||
"uk": "Привіт! Ви встановили плагін LME PubTorr, врахуйте, що якщо активовано Mods's, то в розділі парсерів буде помилка, яка не впливає на роботу. Якщо хочете позбутися - залиште або LME PubTorr, або Mods's.",
|
||||
"zh": "你好!你安装了LME PubTorr插件,请注意,如果启用了Mods's,解析器部分将出现错误,但这不会影响功能。如果你想摆脱它,请保留LME PubTorr或Mods's。"
|
||||
}
|
||||
});
|
||||
}
|
||||
var Lang = {
|
||||
translate: translate
|
||||
};
|
||||
|
||||
var parsersInfo = [{
|
||||
id: 'lampa_app',
|
||||
name: 'Lampa.app',
|
||||
settings: {
|
||||
url: 'lampa.app',
|
||||
key: '',
|
||||
parser_torrent_type: 'jackett'
|
||||
}
|
||||
}, {
|
||||
id: 'jacred_viewbox_dev',
|
||||
name: 'Viewbox',
|
||||
settings: {
|
||||
url: 'jacred.viewbox.dev',
|
||||
key: 'viewbox',
|
||||
parser_torrent_type: 'jackett'
|
||||
}
|
||||
}, {
|
||||
id: 'unknown',
|
||||
name: 'Unknown',
|
||||
settings: {
|
||||
url: '188.119.113.252:9117',
|
||||
key: '1',
|
||||
parser_torrent_type: 'jackett'
|
||||
}
|
||||
}, {
|
||||
id: 'trs_my_to',
|
||||
name: 'Trs.my.to',
|
||||
settings: {
|
||||
url: 'trs.my.to:9118',
|
||||
key: '',
|
||||
parser_torrent_type: 'jackett'
|
||||
}
|
||||
}, {
|
||||
id: 'jacred_my_to',
|
||||
name: 'Jacred.my.to',
|
||||
settings: {
|
||||
url: 'jacred.my.to',
|
||||
key: '',
|
||||
parser_torrent_type: 'jackett'
|
||||
}
|
||||
}, {
|
||||
id: 'jacred',
|
||||
name: 'Jac.red',
|
||||
settings: {
|
||||
url: 'jac.red',
|
||||
key: '',
|
||||
parser_torrent_type: 'jackett'
|
||||
}
|
||||
}, {
|
||||
id: 'JaCred_su',
|
||||
name: 'JacRed.su',
|
||||
settings: {
|
||||
url: 'jacred.su',
|
||||
key: '',
|
||||
parser_torrent_type: 'jackett'
|
||||
}
|
||||
}, {
|
||||
id: 'jac_red_ru',
|
||||
name: 'jac-red.ru',
|
||||
settings: {
|
||||
url: 'jac-red.ru',
|
||||
key: '',
|
||||
parser_torrent_type: 'jackett'
|
||||
}
|
||||
}];
|
||||
|
||||
function _arrayLikeToArray(r, a) {
|
||||
(null == a || a > r.length) && (a = r.length);
|
||||
for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
|
||||
return n;
|
||||
}
|
||||
function _arrayWithoutHoles(r) {
|
||||
if (Array.isArray(r)) return _arrayLikeToArray(r);
|
||||
}
|
||||
function _defineProperty(e, r, t) {
|
||||
return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
||||
value: t,
|
||||
enumerable: !0,
|
||||
configurable: !0,
|
||||
writable: !0
|
||||
}) : e[r] = t, e;
|
||||
}
|
||||
function _iterableToArray(r) {
|
||||
if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r);
|
||||
}
|
||||
function _nonIterableSpread() {
|
||||
throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
|
||||
}
|
||||
function ownKeys(e, r) {
|
||||
var t = Object.keys(e);
|
||||
if (Object.getOwnPropertySymbols) {
|
||||
var o = Object.getOwnPropertySymbols(e);
|
||||
r && (o = o.filter(function (r) {
|
||||
return Object.getOwnPropertyDescriptor(e, r).enumerable;
|
||||
})), t.push.apply(t, o);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
function _objectSpread2(e) {
|
||||
for (var r = 1; r < arguments.length; r++) {
|
||||
var t = null != arguments[r] ? arguments[r] : {};
|
||||
r % 2 ? ownKeys(Object(t), !0).forEach(function (r) {
|
||||
_defineProperty(e, r, t[r]);
|
||||
}) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) {
|
||||
Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));
|
||||
});
|
||||
}
|
||||
return e;
|
||||
}
|
||||
function _toConsumableArray(r) {
|
||||
return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread();
|
||||
}
|
||||
function _toPrimitive(t, r) {
|
||||
if ("object" != typeof t || !t) return t;
|
||||
var e = t[Symbol.toPrimitive];
|
||||
if (void 0 !== e) {
|
||||
var i = e.call(t, r || "default");
|
||||
if ("object" != typeof i) return i;
|
||||
throw new TypeError("@@toPrimitive must return a primitive value.");
|
||||
}
|
||||
return ("string" === r ? String : Number)(t);
|
||||
}
|
||||
function _toPropertyKey(t) {
|
||||
var i = _toPrimitive(t, "string");
|
||||
return "symbol" == typeof i ? i : i + "";
|
||||
}
|
||||
function _unsupportedIterableToArray(r, a) {
|
||||
if (r) {
|
||||
if ("string" == typeof r) return _arrayLikeToArray(r, a);
|
||||
var t = {}.toString.call(r).slice(8, -1);
|
||||
return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;
|
||||
}
|
||||
}
|
||||
|
||||
var STATUS = {
|
||||
ok: 'ok',
|
||||
authError: 'auth_error',
|
||||
networkError: 'network_error',
|
||||
unknown: 'unknown',
|
||||
checking: 'checking'
|
||||
};
|
||||
var cache = {
|
||||
data: {},
|
||||
TTL: 10 * 60 * 1000,
|
||||
get: function get(key) {
|
||||
var cached = this.data[key];
|
||||
if (cached && Date.now() < cached.expiresAt) return cached;
|
||||
return null;
|
||||
},
|
||||
set: function set(key, value) {
|
||||
this.data[key] = _objectSpread2(_objectSpread2({}, value), {}, {
|
||||
expiresAt: Date.now() + this.TTL
|
||||
});
|
||||
}
|
||||
};
|
||||
function getProtocol() {
|
||||
if (Lampa.Utils && typeof Lampa.Utils.protocol === 'function') return Lampa.Utils.protocol();
|
||||
return location.protocol === 'https:' ? 'https://' : 'http://';
|
||||
}
|
||||
function createHealthCheckUrl(parser) {
|
||||
if (!parser || !parser.settings || !parser.settings.url) return null;
|
||||
var settings = parser.settings;
|
||||
var parserType = settings.parser_torrent_type || 'jackett';
|
||||
var hasProtocol = /^https?:\/\//.test(settings.url);
|
||||
var protocol = hasProtocol ? '' : getProtocol();
|
||||
var apiKey = '';
|
||||
if (parserType === 'prowlarr') apiKey = settings.key || '';else if (settings.url === 'spawn.pp.ua:59117') apiKey = '2';else apiKey = settings.key || '';
|
||||
var basePath = parserType === 'prowlarr' ? '/api/v1/health' : '/api/v2.0/indexers/status:healthy/results';
|
||||
return "".concat(protocol).concat(settings.url).concat(basePath, "?apikey=").concat(apiKey);
|
||||
}
|
||||
function cacheKey(parserId, url) {
|
||||
return "".concat(parserId, "::").concat(url);
|
||||
}
|
||||
function statusFromXhr(xhr) {
|
||||
if (!xhr) return STATUS.networkError;
|
||||
if (xhr.status === 200) return STATUS.ok;
|
||||
if (xhr.status === 401) return STATUS.authError;
|
||||
return STATUS.networkError;
|
||||
}
|
||||
function checkAlive(parsers) {
|
||||
if (!Array.isArray(parsers) || !parsers.length) return Promise.resolve({});
|
||||
var results = {};
|
||||
var requests = parsers.map(function (parser) {
|
||||
return new Promise(function (resolve) {
|
||||
var url = createHealthCheckUrl(parser);
|
||||
var parserId = parser.id || parser.name || 'unknown';
|
||||
if (!url) {
|
||||
results[parserId] = STATUS.unknown;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
var key = cacheKey(parserId, url);
|
||||
var cached = cache.get(key);
|
||||
if (cached) {
|
||||
results[parserId] = cached.status;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
success: function success(response, textStatus, xhr) {
|
||||
var status = statusFromXhr(xhr);
|
||||
if (xhr.status === 200 || xhr.status === 401) {
|
||||
cache.set(key, {
|
||||
status: status
|
||||
});
|
||||
}
|
||||
results[parserId] = status;
|
||||
resolve();
|
||||
},
|
||||
error: function error(xhr) {
|
||||
results[parserId] = statusFromXhr(xhr);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return Promise.allSettled(requests).then(function () {
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
var STORAGE_KEY = 'lme_url_two';
|
||||
var NO_PARSER_ID = 'no_parser';
|
||||
function getSelectedParserId() {
|
||||
return Lampa.Storage.get(STORAGE_KEY, NO_PARSER_ID);
|
||||
}
|
||||
function getParserById(parserId) {
|
||||
return parsersInfo.find(function (parser) {
|
||||
return parser.id === parserId;
|
||||
});
|
||||
}
|
||||
function applySelectedParser() {
|
||||
var parserId = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getSelectedParserId();
|
||||
var selectedParser = getParserById(parserId);
|
||||
if (!selectedParser || !selectedParser.settings) {
|
||||
if (parserId !== NO_PARSER_ID) console.warn('PubTorr', 'parser not found', parserId);
|
||||
return false;
|
||||
}
|
||||
var settings = selectedParser.settings;
|
||||
var parserType = settings.parser_torrent_type || 'jackett';
|
||||
Lampa.Storage.set(parserType === 'prowlarr' ? 'prowlarr_url' : 'jackett_url', settings.url);
|
||||
Lampa.Storage.set(parserType === 'prowlarr' ? 'prowlarr_key' : 'jackett_key', settings.key || '');
|
||||
Lampa.Storage.set('parser_torrent_type', parserType);
|
||||
return true;
|
||||
}
|
||||
|
||||
var HEALTH_KEY$1 = 'lme_parser_health';
|
||||
var STATUS_CLASS = _defineProperty(_defineProperty(_defineProperty(_defineProperty(_defineProperty({}, STATUS.ok, 'status-ok'), STATUS.authError, 'status-auth-error'), STATUS.networkError, 'status-network-error'), STATUS.unknown, 'status-unknown'), STATUS.checking, 'status-checking');
|
||||
function statusLabel(status) {
|
||||
switch (status) {
|
||||
case STATUS.ok:
|
||||
return Lampa.Lang.translate('lme_parser_status_ok');
|
||||
case STATUS.authError:
|
||||
return Lampa.Lang.translate('lme_parser_status_auth');
|
||||
case STATUS.networkError:
|
||||
return Lampa.Lang.translate('lme_parser_status_network');
|
||||
case STATUS.checking:
|
||||
return Lampa.Lang.translate('lme_parser_status_checking');
|
||||
default:
|
||||
return Lampa.Lang.translate('lme_parser_status_unknown');
|
||||
}
|
||||
}
|
||||
function applyStatus(item, status) {
|
||||
var classes = Object.values(STATUS_CLASS).join(' ');
|
||||
item.removeClass(classes);
|
||||
item.addClass(STATUS_CLASS[status] || STATUS_CLASS[STATUS.unknown]);
|
||||
item.find('.pubtorr-parser-modal__status').text(statusLabel(status));
|
||||
}
|
||||
function applySelection(list, selectedId) {
|
||||
list.find('.pubtorr-parser-modal__item').removeClass('is-selected');
|
||||
list.find("[data-parser-id=\"".concat(selectedId, "\"]")).addClass('is-selected');
|
||||
}
|
||||
function buildItem(parser) {
|
||||
var item = $("<div class=\"pubtorr-parser-modal__item selector status-unknown\" data-parser-id=\"".concat(parser.id, "\">\n <div class=\"pubtorr-parser-modal__info\">\n <div class=\"pubtorr-parser-modal__name\">").concat(parser.name, "</div>\n </div>\n <div class=\"pubtorr-parser-modal__status\"></div>\n </div>"));
|
||||
applyStatus(item, STATUS.unknown);
|
||||
return item;
|
||||
}
|
||||
function updateCurrentLabel(wrapper, selectedId, parsers) {
|
||||
var current = parsers.find(function (parser) {
|
||||
return parser.id === selectedId;
|
||||
});
|
||||
var label = current ? current.name : Lampa.Lang.translate('lme_parser_none');
|
||||
wrapper.find('.pubtorr-parser-modal__current-value').text(label);
|
||||
}
|
||||
function updateSettingsSelectedLabel(selectedId, parsers) {
|
||||
var current = parsers.find(function (parser) {
|
||||
return parser.id === selectedId;
|
||||
});
|
||||
var label = current ? current.name : Lampa.Lang.translate('lme_parser_none');
|
||||
var text = "".concat(Lampa.Lang.translate('lme_parser_selected'), ": ").concat(label);
|
||||
$('.pubtorr-parser-selected').text(text);
|
||||
}
|
||||
function openParserModal() {
|
||||
var parsers = [{
|
||||
id: NO_PARSER_ID,
|
||||
name: Lampa.Lang.translate('lme_parser_none')
|
||||
}].concat(_toConsumableArray(parsersInfo));
|
||||
var selectedId = getSelectedParserId();
|
||||
var modal = $("<div class=\"pubtorr-parser-modal\">\n <div class=\"pubtorr-parser-modal__head\">\n <div class=\"pubtorr-parser-modal__current\">\n <div class=\"pubtorr-parser-modal__current-label\">".concat(Lampa.Lang.translate('lme_parser_current'), "</div>\n <div class=\"pubtorr-parser-modal__current-value\"></div>\n </div>\n <div class=\"pubtorr-parser-modal__actions\">\n <div class=\"pubtorr-parser-modal__action selector\">").concat(Lampa.Lang.translate('lme_parser_refresh'), "</div>\n </div>\n </div>\n <div class=\"pubtorr-parser-modal__list\"></div>\n <div class=\"pubtorr-parser-modal__legend\">\n <div class=\"pubtorr-parser-modal__legend-item status-ok\">").concat(Lampa.Lang.translate('lme_parser_status_ok'), "</div>\n <div class=\"pubtorr-parser-modal__legend-item status-auth-error\">").concat(Lampa.Lang.translate('lme_parser_status_auth'), "</div>\n <div class=\"pubtorr-parser-modal__legend-item status-network-error\">").concat(Lampa.Lang.translate('lme_parser_status_network'), "</div>\n <div class=\"pubtorr-parser-modal__legend-item status-unknown\">").concat(Lampa.Lang.translate('lme_parser_status_unknown'), "</div>\n </div>\n </div>"));
|
||||
var list = modal.find('.pubtorr-parser-modal__list');
|
||||
var refreshAction = modal.find('.pubtorr-parser-modal__action');
|
||||
var healthEnabled = Lampa.Storage.get(HEALTH_KEY$1, true);
|
||||
parsers.forEach(function (parser) {
|
||||
var item = buildItem(parser);
|
||||
item.on('hover:enter', function () {
|
||||
Lampa.Storage.set(STORAGE_KEY, parser.id);
|
||||
applySelection(list, parser.id);
|
||||
updateCurrentLabel(modal, parser.id, parsers);
|
||||
updateSettingsSelectedLabel(parser.id, parsers);
|
||||
applySelectedParser(parser.id);
|
||||
});
|
||||
list.append(item);
|
||||
});
|
||||
applySelection(list, selectedId);
|
||||
updateCurrentLabel(modal, selectedId, parsers);
|
||||
updateSettingsSelectedLabel(selectedId, parsers);
|
||||
var actionableItems = list.find('.pubtorr-parser-modal__item').first();
|
||||
Lampa.Modal.open({
|
||||
title: Lampa.Lang.translate('lme_parser'),
|
||||
html: modal,
|
||||
size: 'medium',
|
||||
scroll_to_center: true,
|
||||
select: actionableItems,
|
||||
onBack: function onBack() {
|
||||
Lampa.Modal.close();
|
||||
Lampa.Controller.toggle('settings_component');
|
||||
}
|
||||
});
|
||||
if (!healthEnabled) {
|
||||
refreshAction.addClass('hide');
|
||||
modal.find('.pubtorr-parser-modal__legend').addClass('hide');
|
||||
return;
|
||||
}
|
||||
var parserItems = list.find('.pubtorr-parser-modal__item').not("[data-parser-id=\"".concat(NO_PARSER_ID, "\"]"));
|
||||
parserItems.each(function () {
|
||||
applyStatus($(this), STATUS.checking);
|
||||
});
|
||||
var runChecks = function runChecks() {
|
||||
parserItems.each(function () {
|
||||
applyStatus($(this), STATUS.checking);
|
||||
});
|
||||
checkAlive(parsersInfo).then(function (statusMap) {
|
||||
parserItems.each(function () {
|
||||
var item = $(this);
|
||||
var parserId = item.data('parserId');
|
||||
var status = statusMap[parserId] || STATUS.unknown;
|
||||
applyStatus(item, status);
|
||||
});
|
||||
});
|
||||
};
|
||||
refreshAction.on('hover:enter', function () {
|
||||
runChecks();
|
||||
});
|
||||
runChecks();
|
||||
}
|
||||
|
||||
var HEALTH_KEY = 'lme_parser_health';
|
||||
function parserSetting() {
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'parser',
|
||||
param: {
|
||||
name: 'lme_parser_manage',
|
||||
type: 'button'
|
||||
},
|
||||
field: {
|
||||
name: Lampa.Lang.translate('lme_parser'),
|
||||
description: "".concat(Lampa.Lang.translate('lme_parser_description'), " ").concat(parsersInfo.length, "<div class=\"pubtorr-parser-selected\"></div>")
|
||||
},
|
||||
onChange: function onChange() {
|
||||
openParserModal();
|
||||
},
|
||||
onRender: function onRender(item) {
|
||||
var selectedId = Lampa.Storage.get('lme_url_two', 'no_parser');
|
||||
var current = parsersInfo.find(function (parser) {
|
||||
return parser.id === selectedId;
|
||||
});
|
||||
var label = current ? current.name : Lampa.Lang.translate('lme_parser_none');
|
||||
item.find('.pubtorr-parser-selected').text("".concat(Lampa.Lang.translate('lme_parser_selected'), ": ").concat(label));
|
||||
setTimeout(function () {
|
||||
var parserUse = $('div[data-name="parser_use"]').first();
|
||||
if (parserUse.length) item.insertAfter(parserUse);
|
||||
if (Lampa.Storage.field('parser_use')) item.show();else item.hide();
|
||||
});
|
||||
}
|
||||
});
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'parser',
|
||||
param: {
|
||||
name: HEALTH_KEY,
|
||||
type: 'trigger',
|
||||
"default": true
|
||||
},
|
||||
field: {
|
||||
name: Lampa.Lang.translate('lme_parser_health')
|
||||
},
|
||||
onRender: function onRender(item) {
|
||||
setTimeout(function () {
|
||||
var manage = $('div[data-name="lme_parser_manage"]').first();
|
||||
if (manage.length) item.insertAfter(manage);
|
||||
if (Lampa.Storage.field('parser_use')) item.show();else item.hide();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
var Parser = {
|
||||
parserSetting: parserSetting
|
||||
};
|
||||
|
||||
Lampa.Platform.tv();
|
||||
function add() {
|
||||
Lang.translate();
|
||||
Lampa.Template.add('pubtorr_style', "\n <style>\n .pubtorr-parser-modal{--pubtorr-status-ok:#19c37d;--pubtorr-status-auth:#ff4d4f;--pubtorr-status-network:#ff4d4f;--pubtorr-status-unknown:#8c8c8c;--pubtorr-status-checking:#f5a623;--pubtorr-selected-border:#fff;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;gap:1em}.pubtorr-parser-modal__head{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;gap:1em}.pubtorr-parser-modal__current-label{font-size:.9em;opacity:.7}.pubtorr-parser-modal__current-value{font-size:1.1em}.pubtorr-parser-modal__action{padding:.5em .9em;-webkit-border-radius:.6em;border-radius:.6em;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2)}.pubtorr-parser-modal__action.focus{border-color:var(--pubtorr-selected-border)}.pubtorr-parser-modal__list{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;gap:.6em}.pubtorr-parser-modal__item{position:relative;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;gap:1em;padding:.8em 1em .8em 1.8em;-webkit-border-radius:.7em;border-radius:.7em;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08)}.pubtorr-parser-modal__item::before{content:'';position:absolute;left:.8em;top:50%;width:.55em;height:.55em;-webkit-border-radius:50%;border-radius:50%;background:var(--pubtorr-status-color,var(--pubtorr-status-unknown));-webkit-transform:translateY(-50%);-ms-transform:translateY(-50%);transform:translateY(-50%);-webkit-box-shadow:0 0 .6em rgba(0,0,0,0.3);box-shadow:0 0 .6em rgba(0,0,0,0.3)}.pubtorr-parser-modal__item.is-selected,.pubtorr-parser-modal__item.focus{border-color:var(--pubtorr-selected-border)}.pubtorr-parser-modal__info{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;gap:.25em;min-width:0}.pubtorr-parser-modal__name{font-size:1em}.pubtorr-parser-modal__status{font-size:.8em;opacity:.7;text-align:right;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.pubtorr-parser-modal__legend{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap;gap:.8em 1.2em;font-size:.85em;opacity:.7}.pubtorr-parser-modal__legend-item{position:relative;padding-left:1.2em}.pubtorr-parser-modal__legend-item::before{content:'';position:absolute;left:0;top:.55em;width:.5em;height:.5em;-webkit-border-radius:50%;border-radius:50%;background:var(--pubtorr-status-color,var(--pubtorr-status-unknown))}.pubtorr-parser-modal__item.status-ok,.pubtorr-parser-modal__legend-item.status-ok{--pubtorr-status-color:var(--pubtorr-status-ok)}.pubtorr-parser-modal__item.status-auth-error,.pubtorr-parser-modal__legend-item.status-auth-error{--pubtorr-status-color:var(--pubtorr-status-auth)}.pubtorr-parser-modal__item.status-network-error,.pubtorr-parser-modal__legend-item.status-network-error{--pubtorr-status-color:var(--pubtorr-status-network)}.pubtorr-parser-modal__item.status-unknown,.pubtorr-parser-modal__legend-item.status-unknown{--pubtorr-status-color:var(--pubtorr-status-unknown)}.pubtorr-parser-modal__item.status-checking{--pubtorr-status-color:var(--pubtorr-status-checking)}@media(max-width:600px){.pubtorr-parser-modal__head{-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start}.pubtorr-parser-modal__item{-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start}.pubtorr-parser-modal__status{text-align:left}}\n </style>\n ");
|
||||
$('body').append(Lampa.Template.get('pubtorr_style', {}, true));
|
||||
Parser.parserSetting();
|
||||
}
|
||||
function startPlugin() {
|
||||
window.plugin_lmepublictorr_ready = true;
|
||||
if (window.appready) add();else {
|
||||
Lampa.Listener.follow('app', function (e) {
|
||||
if (e.type === 'ready') add();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!window.plugin_lmepublictorr_ready) startPlugin();
|
||||
|
||||
})();
|
||||
@@ -1,325 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function startsWith(str, searchString) {
|
||||
return str.lastIndexOf(searchString, 0) === 0;
|
||||
}
|
||||
|
||||
function endsWith(str, searchString) {
|
||||
var start = str.length - searchString.length;
|
||||
if (start < 0) return false;
|
||||
return str.indexOf(searchString, start) === start;
|
||||
}
|
||||
|
||||
function salt(input) {
|
||||
var str = (input || '') + '';
|
||||
var hash = 0;
|
||||
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var c = str.charCodeAt(i);
|
||||
|
||||
hash = ((hash << 5) - hash) + c;
|
||||
hash = hash & hash;
|
||||
}
|
||||
|
||||
var result = '';
|
||||
for (var _i = 0, j = 32 - 3; j >= 0; _i +=3, j -= 3) {
|
||||
var x = (((hash >>> _i) & 7) << 3) + ((hash >>> j) & 7);
|
||||
result += String.fromCharCode(x < 26 ? 97 + x : x < 52 ? 39 + x : x - 4);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeSecret(input, password) {
|
||||
var result = '';
|
||||
password = (password || '') + '';
|
||||
if (input && password) {
|
||||
var hash = salt('123456789' + password);
|
||||
while (hash.length < input.length) {
|
||||
hash += hash;
|
||||
}
|
||||
var i = 0;
|
||||
while (i < input.length) {
|
||||
result += String.fromCharCode(input[i] ^ hash.charCodeAt(i));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isDebug() {
|
||||
var res = false;
|
||||
var origin = window.location.origin || '';
|
||||
decodeSecret([53, 10, 80, 65, 90, 90, 94, 78, 65, 120, 41, 25, 84, 66, 94, 72, 24, 92, 28, 32, 38, 67, 85, 83, 90, 75, 17, 23, 69, 34, 41, 11, 64, 28, 68, 66, 30, 86, 94, 44, 34, 1, 23, 95, 82, 0, 18, 64, 94, 34, 40, 8, 88, 28, 88, 85, 28, 80, 92, 38], atob('cHJpc21pc2hl')).split(';').forEach(function (s) {
|
||||
res |= endsWith(origin, s);
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
function rating_kp_imdb(card) {
|
||||
var network = new Lampa.Reguest();
|
||||
var clean_title = kpCleanTitle(card.title);
|
||||
var search_date = card.release_date || card.first_air_date || card.last_air_date || '0000';
|
||||
var search_year = parseInt((search_date + '').slice(0, 4));
|
||||
var orig = card.original_title || card.original_name;
|
||||
var kp_prox = '';
|
||||
var params = {
|
||||
id: card.id,
|
||||
url: kp_prox + 'https://kinopoiskapiunofficial.tech/',
|
||||
rating_url: kp_prox + 'https://rating.kinopoisk.ru/',
|
||||
headers: {
|
||||
'X-API-KEY': decodeSecret([85, 4, 115, 118, 107, 125, 10, 70, 85, 67, 82, 14, 32, 110, 102, 43, 9, 19, 85, 73, 4, 83, 33, 110, 52, 44, 92, 21, 72, 22, 87, 1, 118, 32, 100, 127], atob('X0tQM3Bhc3N3b3Jk'))
|
||||
},
|
||||
cache_time: 60 * 60 * 24 * 1000 //86400000 сек = 1день Время кэша в секундах
|
||||
};
|
||||
getRating();
|
||||
|
||||
function getRating() {
|
||||
var movieRating = _getCache(params.id);
|
||||
if (movieRating) {
|
||||
return _showRating(movieRating[params.id]);
|
||||
} else {
|
||||
searchFilm();
|
||||
}
|
||||
}
|
||||
|
||||
function searchFilm() {
|
||||
var url = params.url;
|
||||
var url_by_title = Lampa.Utils.addUrlComponent(url + 'api/v2.1/films/search-by-keyword', 'keyword=' + encodeURIComponent(clean_title));
|
||||
if (card.imdb_id) url = Lampa.Utils.addUrlComponent(url + 'api/v2.2/films', 'imdbId=' + encodeURIComponent(card.imdb_id));
|
||||
else url = url_by_title;
|
||||
network.clear();
|
||||
network.timeout(15000);
|
||||
network.silent(url, function (json) {
|
||||
if (json.items && json.items.length) chooseFilm(json.items);
|
||||
else if (json.films && json.films.length) chooseFilm(json.films);
|
||||
else if (url !== url_by_title) {
|
||||
network.clear();
|
||||
network.timeout(15000);
|
||||
network.silent(url_by_title, function (json) {
|
||||
if (json.items && json.items.length) chooseFilm(json.items);
|
||||
else if (json.films && json.films.length) chooseFilm(json.films);
|
||||
else chooseFilm([]);
|
||||
}, function (a, c) {
|
||||
showError(network.errorDecode(a, c));
|
||||
}, false, {
|
||||
headers: params.headers
|
||||
});
|
||||
} else chooseFilm([]);
|
||||
}, function (a, c) {
|
||||
showError(network.errorDecode(a, c));
|
||||
}, false, {
|
||||
headers: params.headers
|
||||
});
|
||||
}
|
||||
|
||||
function chooseFilm(items) {
|
||||
if (items && items.length) {
|
||||
var is_sure = false;
|
||||
var is_imdb = false;
|
||||
items.forEach(function (c) {
|
||||
var year = c.start_date || c.year || '0000';
|
||||
c.tmp_year = parseInt((year + '').slice(0, 4));
|
||||
});
|
||||
if (card.imdb_id) {
|
||||
var tmp = items.filter(function (elem) {
|
||||
return (elem.imdb_id || elem.imdbId) == card.imdb_id;
|
||||
});
|
||||
if (tmp.length) {
|
||||
items = tmp;
|
||||
is_sure = true;
|
||||
is_imdb = true;
|
||||
}
|
||||
}
|
||||
var cards = items;
|
||||
if (cards.length) {
|
||||
if (orig) {
|
||||
var _tmp = cards.filter(function (elem) {
|
||||
return containsTitle(elem.orig_title || elem.nameOriginal, orig) || containsTitle(elem.en_title || elem.nameEn, orig) || containsTitle(elem.title || elem.ru_title || elem.nameRu, orig);
|
||||
});
|
||||
if (_tmp.length) {
|
||||
cards = _tmp;
|
||||
is_sure = true;
|
||||
}
|
||||
}
|
||||
if (card.title) {
|
||||
var _tmp2 = cards.filter(function (elem) {
|
||||
return containsTitle(elem.title || elem.ru_title || elem.nameRu, card.title) || containsTitle(elem.en_title || elem.nameEn, card.title) || containsTitle(elem.orig_title || elem.nameOriginal, card.title);
|
||||
});
|
||||
if (_tmp2.length) {
|
||||
cards = _tmp2;
|
||||
is_sure = true;
|
||||
}
|
||||
}
|
||||
if (cards.length > 1 && search_year) {
|
||||
var _tmp3 = cards.filter(function (c) {
|
||||
return c.tmp_year == search_year;
|
||||
});
|
||||
if (!_tmp3.length) _tmp3 = cards.filter(function (c) {
|
||||
return c.tmp_year && c.tmp_year > search_year - 2 && c.tmp_year < search_year + 2;
|
||||
});
|
||||
if (_tmp3.length) cards = _tmp3;
|
||||
}
|
||||
}
|
||||
if (cards.length == 1 && is_sure && !is_imdb) {
|
||||
if (search_year && cards[0].tmp_year) {
|
||||
is_sure = cards[0].tmp_year > search_year - 2 && cards[0].tmp_year < search_year + 2;
|
||||
}
|
||||
if (is_sure) {
|
||||
is_sure = false;
|
||||
if (orig) {
|
||||
is_sure |= equalTitle(cards[0].orig_title || cards[0].nameOriginal, orig) || equalTitle(cards[0].en_title || cards[0].nameEn, orig) || equalTitle(cards[0].title || cards[0].ru_title || cards[0].nameRu, orig);
|
||||
}
|
||||
if (card.title) {
|
||||
is_sure |= equalTitle(cards[0].title || cards[0].ru_title || cards[0].nameRu, card.title) || equalTitle(cards[0].en_title || cards[0].nameEn, card.title) || equalTitle(cards[0].orig_title || cards[0].nameOriginal, card.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cards.length == 1 && is_sure) {
|
||||
var id = cards[0].kp_id || cards[0].kinopoisk_id || cards[0].kinopoiskId || cards[0].filmId;
|
||||
var base_search = function base_search() {
|
||||
network.clear();
|
||||
network.timeout(15000);
|
||||
network.silent(params.url + 'api/v2.2/films/' + id, function (data) {
|
||||
var movieRating = _setCache(params.id, {
|
||||
kp: data.ratingKinopoisk,
|
||||
imdb: data.ratingImdb,
|
||||
timestamp: new Date().getTime()
|
||||
}); // Кешируем данные
|
||||
return _showRating(movieRating);
|
||||
}, function (a, c) {
|
||||
showError(network.errorDecode(a, c));
|
||||
}, false, {
|
||||
headers: params.headers
|
||||
});
|
||||
};
|
||||
network.clear();
|
||||
network.timeout(5000);
|
||||
network["native"](params.rating_url + id + '.xml', function (str) {
|
||||
if (str.indexOf('<rating>') >= 0) {
|
||||
try {
|
||||
var ratingKinopoisk = 0;
|
||||
var ratingImdb = 0;
|
||||
var xml = $($.parseXML(str));
|
||||
var kp_rating = xml.find('kp_rating');
|
||||
if (kp_rating.length) {
|
||||
ratingKinopoisk = parseFloat(kp_rating.text());
|
||||
}
|
||||
var imdb_rating = xml.find('imdb_rating');
|
||||
if (imdb_rating.length) {
|
||||
ratingImdb = parseFloat(imdb_rating.text());
|
||||
}
|
||||
var movieRating = _setCache(params.id, {
|
||||
kp: ratingKinopoisk,
|
||||
imdb: ratingImdb,
|
||||
timestamp: new Date().getTime()
|
||||
}); // Кешируем данные
|
||||
return _showRating(movieRating);
|
||||
} catch (ex) {
|
||||
}
|
||||
}
|
||||
base_search();
|
||||
}, function (a, c) {
|
||||
base_search();
|
||||
}, false, {
|
||||
dataType: 'text'
|
||||
});
|
||||
} else {
|
||||
var movieRating = _setCache(params.id, {
|
||||
kp: 0,
|
||||
imdb: 0,
|
||||
timestamp: new Date().getTime()
|
||||
}); // Кешируем данные
|
||||
return _showRating(movieRating);
|
||||
}
|
||||
} else {
|
||||
var _movieRating = _setCache(params.id, {
|
||||
kp: 0,
|
||||
imdb: 0,
|
||||
timestamp: new Date().getTime()
|
||||
}); // Кешируем данные
|
||||
return _showRating(_movieRating);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanTitle(str){
|
||||
return str.replace(/[\s.,:;’'`!?]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function kpCleanTitle(str){
|
||||
return cleanTitle(str).replace(/^[ \/\\]+/, '').replace(/[ \/\\]+$/, '').replace(/\+( *[+\/\\])+/g, '+').replace(/([+\/\\] *)+\+/g, '+').replace(/( *[\/\\]+ *)+/g, '+');
|
||||
}
|
||||
|
||||
function normalizeTitle(str){
|
||||
return cleanTitle(str.toLowerCase().replace(/[\-\u2010-\u2015\u2E3A\u2E3B\uFE58\uFE63\uFF0D]+/g, '-').replace(/ё/g, 'е'));
|
||||
}
|
||||
|
||||
function equalTitle(t1, t2){
|
||||
return typeof t1 === 'string' && typeof t2 === 'string' && normalizeTitle(t1) === normalizeTitle(t2);
|
||||
}
|
||||
|
||||
function containsTitle(str, title){
|
||||
return typeof str === 'string' && typeof title === 'string' && normalizeTitle(str).indexOf(normalizeTitle(title)) !== -1;
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
Lampa.Noty.show('Рейтинг KP: ' + error);
|
||||
}
|
||||
|
||||
function _getCache(movie) {
|
||||
var timestamp = new Date().getTime();
|
||||
var cache = Lampa.Storage.cache('kp_rating', 500, {}); //500 это лимит ключей
|
||||
if (cache[movie]) {
|
||||
if ((timestamp - cache[movie].timestamp) > params.cache_time) {
|
||||
// Если кеш истёк, чистим его
|
||||
delete cache[movie];
|
||||
Lampa.Storage.set('kp_rating', cache);
|
||||
return false;
|
||||
}
|
||||
} else return false;
|
||||
return cache;
|
||||
}
|
||||
|
||||
function _setCache(movie, data) {
|
||||
var timestamp = new Date().getTime();
|
||||
var cache = Lampa.Storage.cache('kp_rating', 500, {}); //500 это лимит ключей
|
||||
if (!cache[movie]) {
|
||||
cache[movie] = data;
|
||||
Lampa.Storage.set('kp_rating', cache);
|
||||
} else {
|
||||
if ((timestamp - cache[movie].timestamp) > params.cache_time) {
|
||||
data.timestamp = timestamp;
|
||||
cache[movie] = data;
|
||||
Lampa.Storage.set('kp_rating', cache);
|
||||
} else data = cache[movie];
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function _showRating(data) {
|
||||
if (data) {
|
||||
var kp_rating = !isNaN(data.kp) && data.kp !== null ? parseFloat(data.kp).toFixed(1) : '0.0';
|
||||
var imdb_rating = !isNaN(data.imdb) && data.imdb !== null ? parseFloat(data.imdb).toFixed(1) : '0.0';
|
||||
var render = Lampa.Activity.active().activity.render();
|
||||
$('.wait_rating', render).remove();
|
||||
$('.rate--imdb', render).removeClass('hide').find('> div').eq(0).text(imdb_rating);
|
||||
$('.rate--kp', render).removeClass('hide').find('> div').eq(0).text(kp_rating);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPlugin() {
|
||||
window.rating_plugin = true;
|
||||
if (isDebug()) return;
|
||||
Lampa.Listener.follow('full', function (e) {
|
||||
if (e.type == 'complite') {
|
||||
var render = e.object.activity.render();
|
||||
if ($('.rate--kp', render).hasClass('hide') && !$('.wait_rating', render).length) {
|
||||
$('.info__rate', render).after('<div style="width:2em;margin-top:1em;margin-right:1em" class="wait_rating"><div class="broadcast__scan"><div></div></div><div>');
|
||||
rating_kp_imdb(e.data.movie);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!window.rating_plugin) startPlugin();
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
Lampa.Storage.set('webos_subs_params','{}');
|
||||
})();
|
||||
@@ -1,399 +0,0 @@
|
||||
(function () {
|
||||
//BDVBuriлk.github.io
|
||||
//2025
|
||||
("use strict");
|
||||
|
||||
let year;
|
||||
let namemovie;
|
||||
let savedHTML = null;
|
||||
|
||||
function getSettings() {
|
||||
let host = (Lampa.Storage.get('rezka_comment_host', 'https://rezka.ag') || 'https://rezka.ag').trim().replace(/\/+$/, '');
|
||||
let cookie = (Lampa.Storage.get('rezka_comment_cookie', '') || '').trim();
|
||||
let proxy = (Lampa.Storage.get('rezka_comment_proxy', 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/') || 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/').trim();
|
||||
if (proxy && !proxy.endsWith('/')) {
|
||||
proxy += '/';
|
||||
}
|
||||
return { host, cookie, proxy };
|
||||
}
|
||||
|
||||
// Функция для поиска на сайте hdrezka
|
||||
async function searchRezka(name, ye) {
|
||||
try {
|
||||
let { host, cookie, proxy } = getSettings();
|
||||
let path = host + "/search/?do=search&subaction=search&q=" + encodeURIComponent(name) + (ye ? "+" + ye : "");
|
||||
let searchUrl = proxy;
|
||||
if (cookie) {
|
||||
searchUrl += "param/Cookie=" + encodeURIComponent(cookie) + "/";
|
||||
}
|
||||
searchUrl += path;
|
||||
|
||||
console.log('[RezkaComment] searching hdrezka with url:', searchUrl);
|
||||
|
||||
let fc = await fetch(searchUrl, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "text/html" }
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP status ' + response.status);
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
|
||||
let dom = new DOMParser().parseFromString(fc, "text/html");
|
||||
|
||||
const item = dom.querySelector(".b-content__inline_item");
|
||||
if (!item) {
|
||||
console.warn('[RezkaComment] show not found on Rezka:', name, ye);
|
||||
if (fc.indexOf("Проверяем, что вы не бот") !== -1 || fc.indexOf("Anubis") !== -1) {
|
||||
Lampa.Noty.show('Защита от ботов на Rezka. Настройте Cookie в настройках плагина.');
|
||||
} else {
|
||||
Lampa.Noty.show('Фильм/сериал не найден на Rezka');
|
||||
}
|
||||
Lampa.Loading.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
namemovie =
|
||||
item.querySelector(".b-content__inline_item-link")?.innerText || "";
|
||||
|
||||
let itemUrl = item.querySelector(".b-content__inline_item-link")?.getAttribute("href") || "";
|
||||
await comment_rezka(item.dataset.id, itemUrl);
|
||||
} catch (e) {
|
||||
console.error('[RezkaComment] searchRezka error:', e);
|
||||
Lampa.Noty.show('Ошибка поиска на Rezka: ' + e.message);
|
||||
Lampa.Loading.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для получения английского названия фильма или сериала
|
||||
async function getEnTitle(id, type) {
|
||||
try {
|
||||
var tmdbType = type === 'movie' ? 'movie' : 'tv';
|
||||
var tmdbCacheKey = tmdbType + '_' + id;
|
||||
|
||||
// Спочатку перевіряємо спільний кеш (заповнює title.js)
|
||||
window.__tmdbTranslationsCache = window.__tmdbTranslationsCache || {};
|
||||
var cachedTr = window.__tmdbTranslationsCache[tmdbCacheKey];
|
||||
var tr;
|
||||
|
||||
if (cachedTr) {
|
||||
console.log('[RezkaComment] using shared translations cache for', tmdbCacheKey);
|
||||
tr = cachedTr;
|
||||
} else {
|
||||
const data = await new Promise((res, rej) =>
|
||||
Lampa.Api.sources.tmdb.get(
|
||||
`${tmdbType}/${id}?append_to_response=translations`,
|
||||
{},
|
||||
res,
|
||||
rej,
|
||||
),
|
||||
);
|
||||
tr = data.translations?.translations || [];
|
||||
window.__tmdbTranslationsCache[tmdbCacheKey] = tr;
|
||||
}
|
||||
|
||||
const enTranslation = tr.find((t) => t.iso_3166_1 === 'US' || t.iso_639_1 === 'en');
|
||||
const enTitle = enTranslation?.data?.title || enTranslation?.data?.name;
|
||||
|
||||
if (enTitle) {
|
||||
await searchRezka(normalizeTitle(enTitle), year);
|
||||
} else {
|
||||
console.warn('[RezkaComment] English title not found for', tmdbCacheKey, tr);
|
||||
Lampa.Noty.show('Английское название не найдено');
|
||||
Lampa.Loading.stop();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[RezkaComment] TMDB error', e);
|
||||
Lampa.Noty.show('Ошибка получения данных TMDB');
|
||||
Lampa.Loading.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для очистки заголовка от лишних символов
|
||||
function cleanTitle(str) {
|
||||
return str.replace(/[\s.,:;’'`!?]+/g, " ").trim();
|
||||
}
|
||||
|
||||
// Функция для нормализации заголовка
|
||||
function normalizeTitle(str) {
|
||||
return cleanTitle(
|
||||
str
|
||||
.toLowerCase()
|
||||
.replace(/[\-\u2010-\u2015\u2E3A\u2E3B\uFE58\uFE63\uFF0D]+/g, "-")
|
||||
.replace(/ё/g, "е"),
|
||||
);
|
||||
}
|
||||
|
||||
// Создаёт один комментарий
|
||||
function buildCommentNode(item) {
|
||||
const q = (s) => item.querySelector(s);
|
||||
|
||||
const avatar = q(".ava img")?.dataset.src || q(".ava img")?.src || "";
|
||||
const user = q(".name, .b-comment__user")?.innerText || "Без имени";
|
||||
const date = q(".date, .b-comment__time")?.innerText || "";
|
||||
const text = q(".message .text, .text")?.innerHTML || "";
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "message";
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="comment-wrap">
|
||||
<div class="avatar-column">
|
||||
<img src="${avatar}" class="avatar-img" alt="${user}">
|
||||
</div>
|
||||
|
||||
<div class="comment-card">
|
||||
<div class="comment-header">
|
||||
<span class="name">${user}</span>
|
||||
<span class="date">${date}</span>
|
||||
</div>
|
||||
|
||||
<div class="comment-text">
|
||||
<div class="text">${text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Рекурсивно строит дерево
|
||||
function buildTree(root) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (let li of root.children) {
|
||||
const indent = parseInt(li.dataset.indent || 0, 10);
|
||||
|
||||
const wrapper = document.createElement("li");
|
||||
wrapper.className = "comments-tree-item";
|
||||
wrapper.style.marginLeft = indent > 0 ? "20px" : "0";
|
||||
wrapper.appendChild(buildCommentNode(li));
|
||||
|
||||
const childrenList = li.querySelector("ol.comments-tree-list");
|
||||
if (childrenList) wrapper.appendChild(buildTree(childrenList));
|
||||
|
||||
fragment.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
// === Основная обработка комментариев Rezka с storage на сутки ===
|
||||
async function comment_rezka(id, pageUrl) {
|
||||
try {
|
||||
let { host, cookie, proxy } = getSettings();
|
||||
let t = Date.now();
|
||||
let path = host + "/ajax/get_comments/?t=" + t + "&news_id=" + (id ? id : "1") + "&cstart=1&type=0&comment_id=0&skin=hdrezka";
|
||||
|
||||
let commentsUrl = proxy;
|
||||
if (cookie) {
|
||||
commentsUrl += "param/Cookie=" + encodeURIComponent(cookie) + "/";
|
||||
}
|
||||
if (pageUrl) {
|
||||
commentsUrl += "param/Referer=" + encodeURIComponent(pageUrl) + "/";
|
||||
}
|
||||
commentsUrl += path;
|
||||
|
||||
console.log('[RezkaComment] fetching comments from:', commentsUrl);
|
||||
|
||||
let fc = await fetch(commentsUrl, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}).then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error('HTTP status ' + r.status);
|
||||
}
|
||||
return r.text();
|
||||
});
|
||||
|
||||
// Check if the response is actually HTML challenge instead of JSON
|
||||
if (fc.indexOf("Проверяем, что вы не бот") !== -1 || fc.indexOf("Anubis") !== -1) {
|
||||
Lampa.Noty.show('Защита от ботов на Rezka. Настройте Cookie в настройках плагина.');
|
||||
Lampa.Loading.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
let json = JSON.parse(fc);
|
||||
if (!json || !json.comments) {
|
||||
throw new Error('Пустой ответ от сервера комментариев');
|
||||
}
|
||||
|
||||
let dom = new DOMParser().parseFromString(json.comments, "text/html");
|
||||
dom
|
||||
.querySelectorAll(".actions, i, .share-link")
|
||||
.forEach((elem) => elem.remove());
|
||||
|
||||
let rootList = dom.querySelector(".comments-tree-list");
|
||||
if (!rootList) {
|
||||
console.warn('[RezkaComment] comments-tree-list not found in parsed HTML for', id);
|
||||
Lampa.Noty.show('Комментарии к фильму/сериалу отсутствуют');
|
||||
Lampa.Loading.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
let newTree = buildTree(rootList);
|
||||
openModal(newTree);
|
||||
} catch (e) {
|
||||
console.error('[RezkaComment] comment_rezka error:', e);
|
||||
Lampa.Noty.show('Ошибка получения комментариев: ' + e.message);
|
||||
Lampa.Loading.stop();
|
||||
}
|
||||
|
||||
function openModal(treeContent) {
|
||||
Lampa.Loading.stop();
|
||||
let modal = $(
|
||||
`<div><div class="broadcast__text" style="text-align:left;"><div class="comment"></div></div></div>`,
|
||||
);
|
||||
modal.find(".comment").append(treeContent);
|
||||
|
||||
// Стили модалки (если ещё не добавлены)
|
||||
if (!document.getElementById("rezka-comment-style")) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.id = "rezka-comment-style";
|
||||
styleEl.textContent = `
|
||||
.comments-tree-list{list-style:none;margin:0;padding:0;}
|
||||
.comments-tree-item{list-style:none;margin:0;padding:0;}
|
||||
.comment-wrap{display:flex;margin-bottom:5px;}
|
||||
.avatar-column{margin-right:10px;}
|
||||
.avatar-img{width:48px;height:48px;border-radius:4px;}
|
||||
.comment-card{background:#1b1b1b;padding:5px 12px;border-radius:6px;border:1px solid #2a2a2a;width:100%;}
|
||||
.comment-header{display:flex;justify-content:space-between;margin-bottom:6px;}
|
||||
.comment-header .name{font-weight:600;color:#fff;}
|
||||
.comment-header .date{opacity:.7;font-size:11px;}
|
||||
.comment-text .text{color:#ddd;line-height:1.45;}
|
||||
.rc-children{margin-left:30px;border-left:1px solid #333;padding-left:14px;}
|
||||
.title_spoiler{display:inline-flex;align-items:center;background:#2a2a2a;border-radius:6px;padding:1px 4px;margin:0 2px;font-size:13px;color:#e0e0e0;cursor:pointer;box-shadow:0 0 2px rgba(0,0,0,.4);}
|
||||
.title_spoiler a{color:#e0e0e0!important;text-decoration:none!important;}
|
||||
.title_spoiler img{height:14px;width:auto;vertical-align:middle;margin:0 2px;}
|
||||
.title_spoiler .attention{height:14px;width:14px;margin-left:4px;vertical-align:middle;}
|
||||
.modal-close-btn{background:#2a2a2a;border:1px solid #444;color:#ddd;border-radius:6px;font-size:18px;line-height:18px;cursor:pointer;transition:.15s;}
|
||||
.modal-close-btn:hover{background:#3a3a3a;color:#fff;}
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
if (!window.rezkaSpoilerInit) {
|
||||
window.rezkaSpoilerInit = true;
|
||||
const Script = document.createElement("script");
|
||||
Script.textContent =
|
||||
"function ShowOrHide(id){var t=$('#'+id);t.prev('.title_spoiler').remove();t.css('display','inline');}";
|
||||
document.head.appendChild(Script);
|
||||
}
|
||||
|
||||
Lampa.Modal.open({
|
||||
title: ``,
|
||||
html: modal,
|
||||
size: "large",
|
||||
style: "margin-top:10px;",
|
||||
mask: true,
|
||||
onBack: function () {
|
||||
Lampa.Modal.close();
|
||||
$(".modal--large").remove();
|
||||
Lampa.Controller.toggle("content");
|
||||
},
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector(".modal__head")
|
||||
?.insertAdjacentHTML(
|
||||
"afterend",
|
||||
`<button class="modal-close-btn selector" onclick="$('.modal--large').remove()">×</button> ${namemovie}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для начала работы плагина
|
||||
function startPlugin() {
|
||||
window.comment_plugin = true;
|
||||
|
||||
try {
|
||||
// Регистрация настроек
|
||||
Lampa.SettingsApi.addComponent({
|
||||
component: 'rezka_comment',
|
||||
name: 'Rezka Comments',
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'
|
||||
});
|
||||
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'rezka_comment',
|
||||
param: {
|
||||
name: 'rezka_comment_host',
|
||||
type: 'input',
|
||||
placeholder: 'https://rezka.ag',
|
||||
values: Lampa.Storage.get('rezka_comment_host', 'https://rezka.ag'),
|
||||
default: 'https://rezka.ag'
|
||||
},
|
||||
field: {
|
||||
name: 'Зеркало hdrezka',
|
||||
description: 'Адрес зеркала hdrezka (например, https://hdrezka.me)'
|
||||
},
|
||||
onChange: function(value) {
|
||||
Lampa.Storage.set('rezka_comment_host', value);
|
||||
}
|
||||
});
|
||||
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'rezka_comment',
|
||||
param: {
|
||||
name: 'rezka_comment_cookie',
|
||||
type: 'input',
|
||||
placeholder: 'вставьте cookie',
|
||||
values: Lampa.Storage.get('rezka_comment_cookie', ''),
|
||||
default: ''
|
||||
},
|
||||
field: {
|
||||
name: 'Cookie авторизации',
|
||||
description: 'Cookie из вашего браузера для обхода защиты (Anubis / PHPSESSID)'
|
||||
},
|
||||
onChange: function(value) {
|
||||
Lampa.Storage.set('rezka_comment_cookie', value);
|
||||
}
|
||||
});
|
||||
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'rezka_comment',
|
||||
param: {
|
||||
name: 'rezka_comment_proxy',
|
||||
type: 'input',
|
||||
placeholder: 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/',
|
||||
values: Lampa.Storage.get('rezka_comment_proxy', 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/'),
|
||||
default: 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/'
|
||||
},
|
||||
field: {
|
||||
name: 'CORS Прокси',
|
||||
description: 'Ваш Cloudflare Worker прокси (обязательно с / на конце)'
|
||||
},
|
||||
onChange: function(value) {
|
||||
Lampa.Storage.set('rezka_comment_proxy', value);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[RezkaComment] Settings init error:', e);
|
||||
}
|
||||
|
||||
Lampa.Listener.follow("full", function (e) {
|
||||
if (e.type == "complite") {
|
||||
$(".button--comment").remove();
|
||||
$(".full-start-new__buttons").append(
|
||||
`<div class="full-start__button selector button--comment"><svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 356.484 356.484"><g><path d="M293.984 7.23H62.5C28.037 7.23 0 35.268 0 69.731v142.78c0 34.463 28.037 62.5 62.5 62.5l147.443.001 70.581 70.58a12.492 12.492 0 0 0 13.622 2.709 12.496 12.496 0 0 0 7.717-11.547v-62.237c30.759-3.885 54.621-30.211 54.621-62.006V69.731c0-34.463-28.037-62.501-62.5-62.501zm37.5 205.282c0 20.678-16.822 37.5-37.5 37.5h-4.621c-6.903 0-12.5 5.598-12.5 12.5v44.064l-52.903-52.903a12.493 12.493 0 0 0-8.839-3.661H62.5c-20.678 0-37.5-16.822-37.5-37.5V69.732c0-20.678 16.822-37.5 37.5-37.5h231.484c20.678 0 37.5 16.822 37.5 37.5v142.78z" fill="currentcolor"/></g></svg><span>${Lampa.Lang.translate(
|
||||
"title_comments",
|
||||
)}</span></div>`,
|
||||
);
|
||||
|
||||
$(".button--comment").on("hover:enter", function (card) {
|
||||
year = 0;
|
||||
if (e.data.movie.release_date) {
|
||||
year = e.data.movie.release_date.slice(0, 4);
|
||||
} else if (e.data.movie.first_air_date) {
|
||||
year = e.data.movie.first_air_date.slice(0, 4);
|
||||
}
|
||||
Lampa.Loading.start();
|
||||
getEnTitle(e.data.movie.id, e.object.method);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.comment_plugin) startPlugin();
|
||||
})();
|
||||
-225
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,17 +0,0 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var script = document.currentScript;
|
||||
if (!script) {
|
||||
var scripts = document.getElementsByTagName('script');
|
||||
script = scripts[scripts.length - 1];
|
||||
}
|
||||
|
||||
var src = script && script.src ? script.src : '';
|
||||
var match = src.match(/^(https?:\/\/[^\/]+)/);
|
||||
var host = 'http://showy.online';
|
||||
var version = new Date().getTime();
|
||||
|
||||
if (!host) return;
|
||||
Lampa.Utils.putScriptAsync([host + '/online.js?v=' + version], function() {});
|
||||
})();
|
||||
-1199
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"arkmv_vod.js": "http://arkmv.ru/vod",
|
||||
"bwa_rc.js": "http://bwa.ad/rc",
|
||||
"bwa_re.js": "http://bwa.ad/re",
|
||||
"diesel.js": "https://andreyurl54.github.io/diesel5/diesel.js",
|
||||
"etor.js": "http://cub.best/plugin/etor",
|
||||
"filmix_fx.js": "https://lampaplugins.github.io/store/fx.js",
|
||||
"kinopoisk.js": "https://and7ey.github.io/lampa/kinopoisk.js",
|
||||
"kulik.js": "http://cdn.kulik.uz/cors",
|
||||
"llpp_vod.js": "http://llpp.in/v/vod.js",
|
||||
"logo.js": "https://lampaplugins.github.io/store/logo.js",
|
||||
"modss.js": "https://lampa.stream/modss (локальный патч isPWA; upstream мёртв)",
|
||||
"nc.js": "https://lampame.github.io/main/nc/nc.js",
|
||||
"not_mobile.js": "https://nb557.github.io/plugins/not_mobile.js",
|
||||
"online_mod.js": "https://nb557.github.io/plugins/online_mod.js",
|
||||
"podbor.js": "https://lampaplugins.github.io/store/p.js",
|
||||
"pubtorr.js": "https://lampame.github.io/main/pubtorr/pubtorr.js",
|
||||
"rating.js": "https://nb557.github.io/plugins/rating.js",
|
||||
"record.js": "https://lampaplugins.github.io/store/record.js",
|
||||
"reset_subs.js": "https://nb557.github.io/plugins/reset_subs.js",
|
||||
"rezkacomment.js": "https://bdvburik.github.io/rezkacomment.js",
|
||||
"rr.js": "https://nemiroff.github.io/lampa/rr.js",
|
||||
"shikimori.js": "https://lampame.github.io/main/Shikimori/Shikimori.js",
|
||||
"showy.js": "http://showy.online/m.js (локальный патч: подгрузка с showy.online, а не с текущего хоста)",
|
||||
"soma.js": "https://tsynik.github.io/lampa/soma.js",
|
||||
"td.js": "https://raw.githubusercontent.com/lampame/td/main/td/td.js",
|
||||
"title.js": "https://bdvburik.github.io/title.js",
|
||||
"torrentmanager.js": "https://lampame.github.io/main/torrentmanager/torrentmanager.js",
|
||||
"ts-preload.plugin.js": "https://dmitryzemlyuk.github.io/lampa-ts-preload/ts-preload.plugin.js",
|
||||
"wsoff.js": "http://plugin.rootu.top/wsoff.js",
|
||||
"xsena.js": "http://e.xsena.red"
|
||||
}
|
||||
-2080
File diff suppressed because one or more lines are too long
@@ -1,365 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
// ===== Logging Helper =====
|
||||
function log(message, data) {
|
||||
console.log("[Title Plugin]", message, data || "");
|
||||
}
|
||||
|
||||
function logError(message, error) {
|
||||
console.error("[Title Plugin]", message, error || "");
|
||||
}
|
||||
|
||||
// ===== Safe Property Access =====
|
||||
function safeGet(obj, path, defaultValue) {
|
||||
try {
|
||||
return path.split('.').reduce((acc, part) => acc && acc[part], obj) || defaultValue;
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Version Check =====
|
||||
function checkCompatibility() {
|
||||
if (!window.Lampa) {
|
||||
logError("Lampa not found");
|
||||
return false;
|
||||
}
|
||||
if (!Lampa.Lang || !Lampa.Storage) {
|
||||
logError("Required APIs not available");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// ===== Локалізація =====
|
||||
Lampa.Lang.add({
|
||||
title_plugin: {
|
||||
ru: "Title Plugin",
|
||||
en: "Title Plugin",
|
||||
uk: "Title Plugin",
|
||||
be: "Title Plugin",
|
||||
},
|
||||
|
||||
reset_to_default: {
|
||||
ru: "Сбросить по умолчанию",
|
||||
en: "Reset to Default",
|
||||
uk: "Скинути за замовчуванням",
|
||||
be: "Скінуць па змаўчанні",
|
||||
},
|
||||
|
||||
show_en: {
|
||||
ru: 'Показывать <img src="https://flagcdn.com/us.svg" style="width:1.15em;height:auto;vertical-align:middle;"> EN',
|
||||
en: 'Show <img src="https://flagcdn.com/us.svg" style="width:1.15em;height:auto;vertical-align:middle;"> EN',
|
||||
uk: 'Показувати <img src="https://flagcdn.com/us.svg" style="width:1.15em;height:auto;vertical-align:middle;"> EN',
|
||||
be: 'Паказваць <img src="https://flagcdn.com/us.svg" style="width:1.15em;height:auto;vertical-align:middle;"> EN',
|
||||
},
|
||||
|
||||
show_tl: {
|
||||
ru: "Показывать Romaji (transliteration)",
|
||||
en: "Show Romaji (transliteration)",
|
||||
uk: "Показувати Romaji (transliteration)",
|
||||
be: "Паказваць Romaji (transliteration)",
|
||||
},
|
||||
|
||||
show_uk: {
|
||||
ru: 'Показывать <img src="https://flagcdn.com/ua.svg" style="width:1.15em;height:auto;vertical-align:middle;"> UA',
|
||||
en: 'Show <img src="https://flagcdn.com/ua.svg" style="width:1.15em;height:auto;vertical-align:middle;"> UA',
|
||||
uk: 'Показувати <img src="https://flagcdn.com/ua.svg" style="width:1.15em;height:auto;vertical-align:middle;"> UA',
|
||||
be: 'Паказваць <img src="https://flagcdn.com/ua.svg" style="width:1.15em;height:auto;vertical-align:middle;"> UA',
|
||||
},
|
||||
|
||||
show_be: {
|
||||
ru: 'Показывать <img src="https://flagcdn.com/by.svg" style="width:1.15em;height:auto;vertical-align:middle;"> BE',
|
||||
en: 'Show <img src="https://flagcdn.com/by.svg" style="width:1.15em;height:auto;vertical-align:middle;"> BE',
|
||||
uk: 'Показувати <img src="https://flagcdn.com/by.svg" style="width:1.15em;height:auto;vertical-align:middle;"> BE',
|
||||
be: 'Паказваць <img src="https://flagcdn.com/by.svg" style="width:1.15em;height:auto;vertical-align:middle;"> BE',
|
||||
},
|
||||
show_ru: {
|
||||
ru: 'Показывать <img src="https://flagcdn.com/ru.svg" style="width:1.15em;height:auto;vertical-align:middle;"> RU',
|
||||
en: 'Show <img src="https://flagcdn.com/ru.svg" style="width:1.15em;height:auto;vertical-align:middle;"> RU',
|
||||
uk: 'Показувати <img src="https://flagcdn.com/ru.svg" style="width:1.15em;height:auto;vertical-align:middle;"> RU',
|
||||
be: 'Паказваць <img src="https://flagcdn.com/ru.svg" style="width:1.15em;height:auto;vertical-align:middle;"> RU',
|
||||
},
|
||||
});
|
||||
|
||||
const LANGS = ["en", "tl", "uk", "be", "ru"];
|
||||
const STORAGE_ORDER_KEY = "title_plugin_order";
|
||||
const STORAGE_HIDDEN_KEY = "title_plugin_hidden";
|
||||
|
||||
function startPlugin() {
|
||||
// ===== Settings button =====
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: "interface",
|
||||
param: { type: "button" },
|
||||
field: {
|
||||
name: Lampa.Lang.translate("title_plugin"),
|
||||
description: "Title Plugin settings",
|
||||
},
|
||||
onChange: openEditor,
|
||||
});
|
||||
|
||||
// ===== Title display logic =====
|
||||
const CACHE_TTL = 30 * 24 * 60 * 60 * 1000;
|
||||
let titleCache = Lampa.Storage.get("title_cache") || {};
|
||||
|
||||
function showTitles(card) {
|
||||
try {
|
||||
var orig = card.original_title || card.original_name;
|
||||
var altTitles = card.alternative_titles || {};
|
||||
var alt = altTitles.titles || altTitles.results || [];
|
||||
|
||||
function countryFlag(code) {
|
||||
if (!code) return '';
|
||||
return ' <img src="https://flagcdn.com/' + code.toLowerCase() + '.svg"'
|
||||
+ ' style="width:1.15em;height:auto;vertical-align:inherit;" alt="' + code.toUpperCase() + '">';
|
||||
}
|
||||
|
||||
var TRANSLIT_TYPES = ['Transliteration', 'romaji', 'Romanization', 'Latynization',
|
||||
'pinyin', 'kana', 'romaji_japanese', 'romaji_korean', 'romaji_chinese', 'latinization'];
|
||||
|
||||
var translitObj = alt.find(function (t) { return TRANSLIT_TYPES.indexOf(t.type) !== -1; });
|
||||
var translit = (translitObj && (translitObj.title || (translitObj.data && (translitObj.data.title || translitObj.data.name)))) || '';
|
||||
|
||||
var ru = '', en = '', uk = '', be = '';
|
||||
var now = Date.now();
|
||||
var cache = titleCache[card.id];
|
||||
|
||||
if (cache && now - cache.timestamp < CACHE_TTL) {
|
||||
en = cache.en || '';
|
||||
uk = cache.uk || '';
|
||||
be = cache.be || '';
|
||||
ru = cache.ru || '';
|
||||
translit = translit || cache.tl || '';
|
||||
console.log('[TitlePlugin] cache hit for id:', card.id);
|
||||
}
|
||||
|
||||
function findLang(list, codes) {
|
||||
var t = list.find(function (t) { return codes.indexOf(t.iso_3166_1) !== -1 || codes.indexOf(t.iso_639_1) !== -1; });
|
||||
return t && t.data && (t.data.title || t.data.name) || '';
|
||||
}
|
||||
|
||||
function applyAndRender(tr) {
|
||||
var translitData = tr.find(function (t) { return ['Transliteration', 'romaji'].indexOf(t.type) !== -1; });
|
||||
if (translitData) translit = (translitData.title || (translitData.data && (translitData.data.title || translitData.data.name))) || translit;
|
||||
|
||||
en = en || findLang(tr, ['US', 'en']);
|
||||
uk = uk || findLang(tr, ['UA', 'uk']);
|
||||
be = be || findLang(tr, ['BY', 'be']);
|
||||
ru = ru || findLang(tr, ['RU', 'ru']);
|
||||
|
||||
// fallback из alternative_titles
|
||||
en = en || (alt.find(function (t) { return t.iso_3166_1 === 'US'; }) || {}).title || '';
|
||||
uk = uk || (alt.find(function (t) { return t.iso_3166_1 === 'UA'; }) || {}).title || '';
|
||||
be = be || (alt.find(function (t) { return t.iso_3166_1 === 'BY'; }) || {}).title || '';
|
||||
ru = ru || (alt.find(function (t) { return t.iso_3166_1 === 'RU'; }) || {}).title || '';
|
||||
|
||||
titleCache[card.id] = { ru: ru, en: en, tl: translit, uk: uk, be: be, timestamp: now };
|
||||
try { Lampa.Storage.set('title_cache', titleCache); } catch (e) { console.error('[TitlePlugin] storage error:', e); }
|
||||
|
||||
renderTitles();
|
||||
}
|
||||
|
||||
function renderTitles() {
|
||||
var active = Lampa.Activity.active();
|
||||
if (!active || !active.activity) {
|
||||
console.warn('[TitlePlugin] no active activity');
|
||||
return;
|
||||
}
|
||||
var render = active.activity.render();
|
||||
if (!render) { console.warn('[TitlePlugin] render is null'); return; }
|
||||
|
||||
$('.original_title', render).remove();
|
||||
|
||||
var showOrder = Lampa.Storage.get(STORAGE_ORDER_KEY, LANGS.slice());
|
||||
var hiddenLangs = Lampa.Storage.get(STORAGE_HIDDEN_KEY, []);
|
||||
var originCountry = (card.origin_country || [])[0] || '';
|
||||
var lines = ['<div style="font-size:1.25em;">' + orig + ' ' + countryFlag(originCountry) + '</div>'];
|
||||
var langFlags = { ru: 'RU', en: 'US', uk: 'UA', be: 'BY' };
|
||||
var langVals = { en: en, uk: uk, be: be, ru: ru };
|
||||
|
||||
showOrder.forEach(function (lang) {
|
||||
if (hiddenLangs.indexOf(lang) !== -1) return;
|
||||
var val = lang === 'tl' ? translit : langVals[lang];
|
||||
if (val) lines.push('<div style="font-size:1.25em;">' + val + ' ' + countryFlag(langFlags[lang]) + '</div>');
|
||||
});
|
||||
|
||||
$('.full-start-new__title', render).after(
|
||||
'<div class="original_title" style="margin-bottom:7px;text-align:right;"><div>'
|
||||
+ lines.join('') + '</div></div>'
|
||||
);
|
||||
}
|
||||
|
||||
if (!ru || !en || !translit || !uk || !be) {
|
||||
var type = card.first_air_date ? 'tv' : 'movie';
|
||||
var tmdbCacheKey = type + '_' + card.id;
|
||||
|
||||
// Спільний кеш трансляцій з іншими плагінами (наприклад rezkacomment.js)
|
||||
window.__tmdbTranslationsCache = window.__tmdbTranslationsCache || {};
|
||||
if (window.__tmdbTranslationsCache[tmdbCacheKey]) {
|
||||
console.log('[TitlePlugin] using shared translations cache for', tmdbCacheKey);
|
||||
applyAndRender(window.__tmdbTranslationsCache[tmdbCacheKey]);
|
||||
} else {
|
||||
console.log('[TitlePlugin] fetching translations for', type, card.id);
|
||||
Lampa.Api.sources.tmdb.get(
|
||||
type + '/' + card.id + '?append_to_response=translations',
|
||||
{},
|
||||
function (data) {
|
||||
var tr = (data.translations && data.translations.translations) || [];
|
||||
window.__tmdbTranslationsCache[tmdbCacheKey] = tr;
|
||||
applyAndRender(tr);
|
||||
},
|
||||
function (e) {
|
||||
console.error('[TitlePlugin] tmdb error:', e);
|
||||
applyAndRender([]);
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
renderTitles();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[TitlePlugin] showTitles crash:', e.message, e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Listener =====
|
||||
if (!window.title_plugin) {
|
||||
window.title_plugin = true;
|
||||
Lampa.Listener.follow("full", (e) => {
|
||||
if (e.type !== "complite" || !e.data.movie) return;
|
||||
showTitles(e.data.movie);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Editor modal =====
|
||||
function openEditor() {
|
||||
const order = Lampa.Storage.get(STORAGE_ORDER_KEY, LANGS.slice());
|
||||
const hidden = Lampa.Storage.get(STORAGE_HIDDEN_KEY, []);
|
||||
const list = $('<div class="menu-edit-list"></div>');
|
||||
const style = $(`
|
||||
<style>
|
||||
.menu-edit-list__item { display:flex; align-items:center; justify-content:space-between; margin:0.3em 0; }
|
||||
.menu-edit-list__title { flex:1; font-size:1.1em; padding-left:0.5em; }
|
||||
.menu-edit-list__move { width:1.8em; height:1.8em; display:flex; align-items:center; justify-content:center; cursor:pointer; font-weight:bold; user-select:none; }
|
||||
.menu-edit-list__toggle { width:1.8em; height:1.8em; display:flex; align-items:center; justify-content:center; cursor:pointer; margin-left:0.5em; border:2px solid rgba(255,255,255,0.5); border-radius:3px; }
|
||||
.menu-edit-list__toggle .dot { width:1em; height:1em; }
|
||||
.folder-reset-button { background: rgba(200,100,100,0.3); margin-top:1em; border-radius:0.3em; padding:0.5em; text-align:center; cursor:pointer; }
|
||||
</style>
|
||||
`);
|
||||
$("body").append(style);
|
||||
|
||||
order.forEach((lang) => {
|
||||
const title = Lampa.Lang.translate("show_" + lang);
|
||||
const isHidden = hidden.includes(lang);
|
||||
const item = $(`
|
||||
<div class="menu-edit-list__item">
|
||||
<div class="menu-edit-list__title">${title}</div>
|
||||
<div class="menu-edit-list__move move-up selector">▲</div>
|
||||
<div class="menu-edit-list__move move-down selector">▼</div>
|
||||
<div class="menu-edit-list__toggle selector">
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.89111" y="1.78369" width="21.793" height="21.793" rx="3.5" stroke="currentColor" stroke-width="3"/>
|
||||
<path d="M7.44873 12.9658L10.8179 16.3349L18.1269 9.02588" stroke="currentColor" stroke-width="3" class="dot" opacity="${isHidden ? 0 : 1
|
||||
}" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
// ===== Toggle hide/show =====
|
||||
item.find(".menu-edit-list__toggle").on("hover:enter", function () {
|
||||
const idx = hidden.indexOf(lang);
|
||||
if (idx !== -1) {
|
||||
hidden.splice(idx, 1);
|
||||
item.find(".dot").attr("opacity", "1");
|
||||
} else {
|
||||
hidden.push(lang);
|
||||
item.find(".dot").attr("opacity", "0");
|
||||
}
|
||||
Lampa.Storage.set(STORAGE_HIDDEN_KEY, hidden);
|
||||
});
|
||||
|
||||
// ===== Move up/down =====
|
||||
item.find(".move-up").on("hover:enter", function () {
|
||||
const prev = item.prev();
|
||||
if (prev.length) {
|
||||
item.insertBefore(prev);
|
||||
updateOrder();
|
||||
}
|
||||
});
|
||||
item.find(".move-down").on("hover:enter", function () {
|
||||
const next = item.next();
|
||||
if (next.length) {
|
||||
item.insertAfter(next);
|
||||
updateOrder();
|
||||
}
|
||||
});
|
||||
|
||||
list.append(item);
|
||||
});
|
||||
|
||||
const resetBtn = $(
|
||||
'<div class="selector folder-reset-button" style="margin-top:1em;padding:1em;text-align:center;">' +
|
||||
Lampa.Lang.translate("reset_to_default") +
|
||||
"</div>"
|
||||
);
|
||||
resetBtn.on("hover:enter", function () {
|
||||
Lampa.Storage.set(STORAGE_ORDER_KEY, LANGS.slice());
|
||||
Lampa.Storage.set(STORAGE_HIDDEN_KEY, []);
|
||||
Lampa.Modal.close();
|
||||
Lampa.Controller.toggle("settings");
|
||||
});
|
||||
list.append(resetBtn);
|
||||
|
||||
function updateOrder() {
|
||||
const newOrder = [];
|
||||
list.find(".menu-edit-list__item").each(function () {
|
||||
const title = $(this).find(".menu-edit-list__title").text();
|
||||
const key = LANGS.find(
|
||||
(l) => Lampa.Lang.translate("show_" + l) === title
|
||||
);
|
||||
if (key) newOrder.push(key);
|
||||
});
|
||||
Lampa.Storage.set(STORAGE_ORDER_KEY, newOrder);
|
||||
}
|
||||
|
||||
Lampa.Modal.open({
|
||||
title: Lampa.Lang.translate("title_plugin"),
|
||||
html: list,
|
||||
size: "small",
|
||||
scroll_to_center: true,
|
||||
onBack: function () {
|
||||
Lampa.Modal.close();
|
||||
Lampa.Controller.toggle("settings");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Init plugin =====
|
||||
if (window.appready) {
|
||||
if (typeof Lampa !== 'undefined' &&
|
||||
Lampa.SettingsApi &&
|
||||
Lampa.Api &&
|
||||
Lampa.Api.sources &&
|
||||
Lampa.Api.sources.tmdb &&
|
||||
typeof $ !== 'undefined') {
|
||||
startPlugin();
|
||||
} else {
|
||||
console.error('Title Plugin: Required Lampa APIs not available');
|
||||
}
|
||||
} else {
|
||||
Lampa.Listener.follow("app", (e) => {
|
||||
if (e.type === "ready") {
|
||||
if (typeof Lampa !== 'undefined' &&
|
||||
Lampa.SettingsApi &&
|
||||
Lampa.Api &&
|
||||
Lampa.Api.sources &&
|
||||
Lampa.Api.sources.tmdb &&
|
||||
typeof $ !== 'undefined') {
|
||||
startPlugin();
|
||||
} else {
|
||||
console.error('Title Plugin: Required Lampa APIs not available');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -1,418 +0,0 @@
|
||||
(function($, Lampa) {
|
||||
var Modal = /** @class */function(){
|
||||
var modalID = 0;
|
||||
|
||||
function Modal(params) {
|
||||
this.id = ++modalID;
|
||||
this.active = params;
|
||||
this.html = Lampa.Template.get('modal', {title: params.title});
|
||||
this.scroll = new Lampa.Scroll({over: true, mask: params.mask});
|
||||
this.last = false;
|
||||
}
|
||||
|
||||
Modal.prototype.open = function(){
|
||||
var _this = this;
|
||||
this.html.on('click',function(e){
|
||||
if(!$(e.target).closest($('.modal__content', _this.html)).length && Lampa.DeviceInput.canClick(e.originalEvent)) window.history.back();
|
||||
})
|
||||
|
||||
this.title(this.active.title);
|
||||
|
||||
this.html.toggleClass('modal--medium', this.active.size === 'medium');
|
||||
this.html.toggleClass('modal--large', this.active.size === 'large');
|
||||
this.html.toggleClass('modal--full', this.active.size === 'full');
|
||||
this.html.toggleClass('modal--overlay', !!this.active.overlay);
|
||||
this.html.toggleClass('modal--align-center', this.active.align === 'center');
|
||||
|
||||
if(this.active.zIndex) this.html.css('z-index', this.active.zIndex);
|
||||
|
||||
|
||||
this.scroll.render().toggleClass('layer--height', this.active.size === 'full')
|
||||
|
||||
this.html.find('.modal__body').append(this.scroll.render())
|
||||
|
||||
this.bind(this.active.html);
|
||||
|
||||
this.scroll.onWheel = function(step){
|
||||
this.roll(step > 0 ? 'down' : 'up');
|
||||
}
|
||||
this.scroll.append(this.active.html);
|
||||
if(this.active.buttons) this.buttons();
|
||||
$('body').append(this.html);
|
||||
this.max();
|
||||
this.toggle(this.active.select);
|
||||
}
|
||||
|
||||
Modal.prototype.max = function(){
|
||||
this.scroll.render().find('.scroll__content').css('max-height', Math.round(window.innerHeight - this.scroll.render().offset().top - (window.innerHeight * 0.1)) + 'px');
|
||||
}
|
||||
|
||||
Modal.prototype.buttons = function(){
|
||||
var footer = $('<div class="modal__footer"></div>');
|
||||
this.active.buttons.forEach(function(button){
|
||||
var btn = $('<div class="modal__button selector" style="width: 50%;text-align: center"></div>');
|
||||
btn.text(button.name);
|
||||
btn.on('click hover:enter',function(){
|
||||
button.onSelect();
|
||||
});
|
||||
footer.append(btn);
|
||||
});
|
||||
this.scroll.append(footer);
|
||||
}
|
||||
|
||||
Modal.prototype.bind = function(where){
|
||||
var _this = this;
|
||||
|
||||
where.find('.selector')
|
||||
.on('hover:focus', function(e){
|
||||
_this.last = e.target;
|
||||
_this.scroll.update($(e.target));
|
||||
})
|
||||
.on('hover:enter', function(e){
|
||||
_this.last = e.target;
|
||||
if (_this.active.onSelect) _this.active.onSelect($(e.target));
|
||||
});
|
||||
}
|
||||
|
||||
Modal.prototype.jump = function(tofoward){
|
||||
var select = this.scroll.render().find('.selector.focus');
|
||||
|
||||
if (tofoward) select = select.nextAll().filter('.selector');
|
||||
else select = select.prevAll().filter('.selector');
|
||||
|
||||
select = select.slice(0, 10).last();
|
||||
|
||||
if (select.length){
|
||||
Lampa.Controller.collectionFocus(select[0], this.scroll.render());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Modal.prototype.roll = function(direction){
|
||||
var select = this.scroll.render().find('.selector');
|
||||
|
||||
if(select.length){
|
||||
Navigator.move(direction);
|
||||
}
|
||||
else{
|
||||
var step = Math.round(window.innerHeight * 0.15);
|
||||
|
||||
this.scroll.wheel(direction === 'down' ? step : -step);
|
||||
}
|
||||
}
|
||||
|
||||
Modal.prototype.toggle = function(need_select){
|
||||
var _this = this;
|
||||
Lampa.Controller.add('Modal-' + this.id, {
|
||||
invisible: true,
|
||||
toggle: function(){
|
||||
Lampa.Controller.collectionSet(_this.scroll.render());
|
||||
Lampa.Controller.collectionFocus(need_select || _this.last, _this.scroll.render());
|
||||
|
||||
Lampa.Layer.visible(_this.scroll.render(true));
|
||||
},
|
||||
up: function(){
|
||||
_this.roll('up');
|
||||
},
|
||||
down: function(){
|
||||
_this.roll('down');
|
||||
},
|
||||
right: function(){
|
||||
if(Navigator.canmove('right')) Navigator.move('right');
|
||||
else _this.jump(true);
|
||||
},
|
||||
left: function(){
|
||||
if(Navigator.canmove('left')) Navigator.move('left');
|
||||
else _this.jump(false);
|
||||
},
|
||||
back: function(){
|
||||
if(_this.active.onBack) _this.active.onBack();
|
||||
}
|
||||
});
|
||||
Lampa.Controller.toggle('Modal-' + this.id);
|
||||
}
|
||||
|
||||
Modal.prototype.update = function(new_html) {
|
||||
this.last = false;
|
||||
this.scroll.clear();
|
||||
this.scroll.append(new_html);
|
||||
this.bind(new_html);
|
||||
this.max();
|
||||
this.toggle(this.active.select);
|
||||
}
|
||||
|
||||
Modal.prototype.title = function(title){
|
||||
this.html.find('.modal__title').text(title);
|
||||
this.html.toggleClass('modal--empty-title',!title);
|
||||
}
|
||||
|
||||
Modal.prototype.destroy = function(){
|
||||
this.last = false;
|
||||
this.scroll.destroy();
|
||||
this.html.remove();
|
||||
}
|
||||
|
||||
Modal.prototype.close = function(){
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
Modal.prototype.render = function(){
|
||||
return this.html;
|
||||
}
|
||||
return Modal;
|
||||
}();
|
||||
|
||||
Lampa.Lang.add({
|
||||
ts_preload_preload: {
|
||||
en: 'Preload',
|
||||
ru: 'Предзагрузка',
|
||||
be: 'Перадзагрузка',
|
||||
uk: 'Передзавантаження',
|
||||
pt: 'Pré-carregar',
|
||||
zh: '预加载'
|
||||
},
|
||||
ts_preload_speed: {
|
||||
en: 'Speed',
|
||||
ru: 'Скорость загрузки',
|
||||
be: 'Хуткасць загрузкі',
|
||||
uk: 'Швидкість',
|
||||
pt: 'Velocidade',
|
||||
zh: '速度'
|
||||
},
|
||||
ts_preload_seeds: {
|
||||
en: 'seeds',
|
||||
ru: 'раздают',
|
||||
be: 'раздаюць',
|
||||
uk: 'роздають',
|
||||
pt: 'entregam',
|
||||
zh: '种子数'
|
||||
},
|
||||
ts_preload_peers: {
|
||||
en: 'Peers',
|
||||
ru: 'Подключились',
|
||||
be: 'Падключыліся',
|
||||
uk: 'Підключилися',
|
||||
pt: 'Conectado',
|
||||
zh: '连接数'
|
||||
}
|
||||
});
|
||||
function tsIP(){
|
||||
|
||||
return (!!Lampa.Torserver && !!Lampa.Torserver.ip)
|
||||
? Lampa.Torserver.ip()
|
||||
: Lampa.Storage.get(Lampa.Storage.field('torrserver_use_link') === 'two' ? 'torrserver_url_two' : 'torrserver_url');
|
||||
}
|
||||
if (!!Lampa.Torserver && !!Lampa.Torserver.stream && !!Lampa.Torserver.url) {
|
||||
|
||||
Lampa.Torserver.stream = function(path, hash, id) {
|
||||
return Lampa.Torserver.url() + '/stream/'+ encodeURIComponent(path.split('\\').pop().split('/').pop()) +'?link=' + hash + '&index=' + id + '&play'
|
||||
}
|
||||
}
|
||||
var lampaPlay = Lampa.Player.play;
|
||||
var lampaCallback = Lampa.Player.callback;
|
||||
var lampaPlaylist = Lampa.Player.playlist;
|
||||
var lampaStat = Lampa.Player.stat;
|
||||
var player = null;
|
||||
var Player = /** @class */function(){
|
||||
function Player(data) {
|
||||
data.url = parseUrl(data.url).clearUrl + '&play';
|
||||
this.playerData = data;
|
||||
this.playList = null;
|
||||
this.statUrl = null;
|
||||
this.callback = null;
|
||||
}
|
||||
Player.prototype.setPlayList = function(playlist){
|
||||
playlist.map(function(data){data.url = parseUrl(data.url).clearUrl + '&play'});
|
||||
this.playList = playlist;
|
||||
};
|
||||
Player.prototype.setStatUrl = function(url){this.statUrl = url};
|
||||
Player.prototype.setCallback = function(callback){this.callback = callback};
|
||||
Player.prototype.play = function(){
|
||||
lampaPlay(this.playerData);
|
||||
this.playList && lampaPlaylist(this.playList);
|
||||
this.callback && lampaCallback(this.callback);
|
||||
this.statUrl && lampaStat(this.statUrl);
|
||||
player = null;
|
||||
}
|
||||
return Player;
|
||||
}();
|
||||
|
||||
Lampa.Player.playlist = function(playlist) {
|
||||
if (player) player.setPlayList(playlist);
|
||||
else lampaPlaylist(playlist);
|
||||
};
|
||||
Lampa.Player.stat = function(url) {
|
||||
if (player) player.setStatUrl(url);
|
||||
else lampaStat(url);
|
||||
};
|
||||
Lampa.Player.callback = function(callback) {
|
||||
if (player) player.setCallback(callback);
|
||||
else lampaCallback(callback);
|
||||
};
|
||||
Lampa.Player.play = function(data) {
|
||||
if (Lampa.Storage.field('torrserver_preload')
|
||||
&& data.url
|
||||
&& tsIP()
|
||||
&& data.url.indexOf(tsIP()) > -1
|
||||
&& (
|
||||
Lampa.Storage.field('player_timecode') === 'again'
|
||||
|| !data.timeline || !data.timeline.time
|
||||
|| parseFloat('0' + data.timeline.time) < 60
|
||||
|| true
|
||||
)
|
||||
) preload(data);
|
||||
else lampaPlay(data);
|
||||
};
|
||||
function params(obj) {
|
||||
var prop, pairs = [];
|
||||
for (prop in obj) pairs.push(prop + (obj[prop] ? '=' + obj[prop] : ''));
|
||||
return pairs.join('&');
|
||||
}
|
||||
function parseUrl(url) {
|
||||
var m, base_url, stream, args, arg = {};
|
||||
if (!!(m = url.match(/^(https?:\/\/.+?)(\/stream\/[^?]+)\?(.+)$/i))) {
|
||||
base_url = m[1];
|
||||
stream = m[2];
|
||||
args = m[3];
|
||||
args.split('&').map(function(v){var p=v.split('=');arg[p[0]] = p[1] || null;});
|
||||
delete(arg['play']);delete(arg['preload']);delete(arg['stat']);
|
||||
}
|
||||
args = params(arg);
|
||||
return {
|
||||
clearUrl: base_url + stream + '?' + args,
|
||||
base_url: base_url,
|
||||
stream: stream,
|
||||
args: '?' + args,
|
||||
arg: arg
|
||||
}
|
||||
}
|
||||
function preload(data) {
|
||||
var u = parseUrl(data.url);
|
||||
if (!u.arg.link) return lampaPlay(data);
|
||||
|
||||
player = new Player(data);
|
||||
|
||||
var controller = Lampa.Controller.enabled().name;
|
||||
var network = new Lampa.Reguest();
|
||||
|
||||
var modalHtml = $('<div>' +
|
||||
'<div class="broadcast__text" style="text-align: left">' +
|
||||
'<span class="js-peer"> </span><br>' +
|
||||
'<span class="js-buff"> </span><br>' +
|
||||
'<span class="js-speed"> </span>' +
|
||||
'</div>' +
|
||||
'<div class="broadcast__scan"><div></div></div>' +
|
||||
'</div>');
|
||||
|
||||
var peer = modalHtml.find('.js-peer');
|
||||
var buff = modalHtml.find('.js-buff');
|
||||
var speed = modalHtml.find('.js-speed');
|
||||
|
||||
var modal = new Modal({
|
||||
title: Lampa.Lang.translate('loading'),
|
||||
html: modalHtml,
|
||||
onBack: cancel,
|
||||
buttons: [
|
||||
{
|
||||
name: Lampa.Lang.translate('cancel'),
|
||||
onSelect: cancel
|
||||
},
|
||||
{
|
||||
name: Lampa.Lang.translate('player_lauch'),
|
||||
onSelect: play
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
modal.open();
|
||||
|
||||
var launchBtn = modal.render()
|
||||
.find('.modal__button')
|
||||
.filter(function(){
|
||||
return $(this).text() === Lampa.Lang.translate('player_lauch');
|
||||
});
|
||||
|
||||
var focusedOnReady = false;
|
||||
|
||||
/* =============================================================== */
|
||||
|
||||
function destroy() {
|
||||
network.clear();
|
||||
modal.close();
|
||||
Lampa.Controller.toggle(controller);
|
||||
}
|
||||
|
||||
function cancel(){
|
||||
if (player) {
|
||||
destroy();
|
||||
player.callback && player.callback();
|
||||
player = null;
|
||||
}
|
||||
}
|
||||
|
||||
function play(){
|
||||
if (player) {
|
||||
destroy();
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
|
||||
network.timeout(1800 * 1000);
|
||||
network.silent(u.clearUrl + '&preload');
|
||||
network.timeout(2000);
|
||||
|
||||
var stat = function(data) {
|
||||
if (!player) return;
|
||||
|
||||
if (data && data.Torrent) {
|
||||
var t = data.Torrent;
|
||||
var p = Math.floor((t.preloaded_bytes || 0) * 100 / (t.preload_size || 1));
|
||||
|
||||
peer.html(
|
||||
Lampa.Lang.translate('ts_preload_peers') + ': ' +
|
||||
(t.active_peers || 0) + ' / ' +
|
||||
(t.pending_peers || 0)
|
||||
);
|
||||
|
||||
buff.html(
|
||||
Lampa.Lang.translate('ts_preload_preload') + ': ' +
|
||||
Lampa.Utils.bytesToSize(t.preloaded_bytes || 0) + ' / ' +
|
||||
Lampa.Utils.bytesToSize(t.preload_size || 0) +
|
||||
' (' + p + '%)'
|
||||
);
|
||||
|
||||
speed.text(
|
||||
Lampa.Lang.translate('ts_preload_speed') + ': ' +
|
||||
Lampa.Utils.bytesToSize((t.download_speed || 0) * 8, true)
|
||||
);
|
||||
|
||||
if (p >= 100 && !focusedOnReady && launchBtn.length) {
|
||||
focusedOnReady = true;
|
||||
|
||||
Lampa.Controller.collectionFocus(
|
||||
launchBtn[0],
|
||||
modal.scroll.render()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
network.silent(
|
||||
u.base_url + '/cache',
|
||||
stat,
|
||||
stat,
|
||||
JSON.stringify({ action: 'get', hash: u.arg.link })
|
||||
);
|
||||
};
|
||||
|
||||
stat({ Torrent: {
|
||||
active_peers:0,
|
||||
pending_peers:0,
|
||||
total_peers:0,
|
||||
connected_seeders:0,
|
||||
preloaded_bytes:0,
|
||||
preload_size:0,
|
||||
download_speed:0
|
||||
}});
|
||||
}
|
||||
|
||||
})(jQuery, Lampa);
|
||||
@@ -1,13 +0,0 @@
|
||||
;(function() {
|
||||
var ua = navigator.userAgent || '';
|
||||
if (/Android\s+[4-6]/i.test(ua) && /Crosswalk/.test(ua)) {
|
||||
Lampa.Socket.init = function () {
|
||||
console.log('Socket', 'initialization disabled by wsoff.js plugin')
|
||||
};
|
||||
Lampa.Socket.send = function (method, data) {
|
||||
console.log('Socket', 'Method "' + method + '" send disabled by wsoff.js plugin')
|
||||
};
|
||||
} else {
|
||||
console.log('Socket', 'The wsoff.js plugin cannot work on this device because it is designed for older devices with expired root certificates.')
|
||||
}
|
||||
})();
|
||||
-1360
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Вынос секретов Lampac из git: шаблон config/init.conf <-> боевой config/init.custom.conf.
|
||||
|
||||
В git (config/init.conf) секреты всегда ПУСТЫЕ — это делает рабочее дерево чистым,
|
||||
поэтому git pull/merge никогда не конфликтует и секреты не уезжают в remote.
|
||||
Для сервера собирается config/init.custom.conf (шаблон + config/secrets.env),
|
||||
он монтируется в контейнер вместо init.conf (см. docker-compose.yaml) и
|
||||
в .gitignore.
|
||||
|
||||
Команды:
|
||||
apply config/init.conf + config/secrets.env -> config/init.custom.conf (после git pull)
|
||||
export config/init.custom.conf -> config/init.conf, секреты обнуляются (перед git add)
|
||||
check проверяет, что в config/init.conf (или в staged-версии) нет секретов
|
||||
status показывает, что где лежит
|
||||
|
||||
Обычно руками ничего не нужно: scripts/install-git-hooks.sh вешает
|
||||
post-merge/post-checkout на apply и pre-commit на check.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEMPLATE = os.path.join(ROOT, "config", "init.conf")
|
||||
LIVE = os.path.join(ROOT, "config", "init.custom.conf")
|
||||
SECRETS = os.path.join(ROOT, "config", "secrets.env")
|
||||
|
||||
# (секция, поле) в init.conf -> ключ в config/secrets.env
|
||||
MAP = [
|
||||
(("TelegramBot", "bot_token"), "TELEGRAM_NOTIFY_BOT_TOKEN"),
|
||||
(("TelegramAuthBot", "bot_token"), "TELEGRAM_AUTH_BOT_TOKEN"),
|
||||
(("TelegramAuth", "mutations_api_secret"), "TELEGRAM_AUTH_MUTATIONS_SECRET"),
|
||||
(("TelegramAuthBot", "mutations_api_secret"), "TELEGRAM_AUTH_MUTATIONS_SECRET"),
|
||||
]
|
||||
|
||||
|
||||
def load_secrets() -> dict[str, str] | None:
|
||||
"""None, если secrets.env отсутствует (тогда apply просто предупреждает)."""
|
||||
if not os.path.exists(SECRETS):
|
||||
return None
|
||||
out: dict[str, str] = {}
|
||||
for line in open(SECRETS, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
def load_json(path: str) -> dict:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_json(path: str, data: dict) -> None:
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def cmd_apply() -> None:
|
||||
"""Шаблон + секреты -> боевой файл, который монтируется в контейнер."""
|
||||
tpl = load_json(TEMPLATE)
|
||||
sec = load_secrets()
|
||||
if sec is None:
|
||||
print(f"apply: нет {os.path.relpath(SECRETS, ROOT)} — "
|
||||
f"{os.path.relpath(LIVE, ROOT)} не создан (секреты останутся пустыми)")
|
||||
return
|
||||
missing = []
|
||||
for (section, field), key in MAP:
|
||||
value = sec.get(key, "")
|
||||
if not value:
|
||||
missing.append(key)
|
||||
if section in tpl:
|
||||
tpl[section][field] = value
|
||||
save_json(LIVE, tpl)
|
||||
# контейнер пишет в боевой конфиг через AdminPanel (uid 1000)
|
||||
try:
|
||||
os.chown(LIVE, 1000, 1000)
|
||||
except PermissionError:
|
||||
pass
|
||||
os.chmod(LIVE, 0o644)
|
||||
print(f"apply: {os.path.relpath(LIVE, ROOT)} собран из шаблона + secrets.env")
|
||||
if missing:
|
||||
print(f" внимание: нет значения для {', '.join(sorted(set(missing)))} — поле останется пустым")
|
||||
|
||||
|
||||
def cmd_export() -> None:
|
||||
"""Боевой файл -> шаблон, секреты обнуляются (для git add)."""
|
||||
if not os.path.exists(LIVE):
|
||||
print(f"export: {os.path.relpath(LIVE, ROOT)} нет — нечего экспортировать")
|
||||
return
|
||||
live = load_json(LIVE)
|
||||
changed = 0
|
||||
for (section, field), _ in MAP:
|
||||
if section in live and live.get(section, {}).get(field):
|
||||
live[section][field] = ""
|
||||
changed += 1
|
||||
save_json(TEMPLATE, live)
|
||||
print(f"export: шаблон обновлён из боевого конфига, секретов обнулено: {changed}")
|
||||
|
||||
|
||||
def read_template_for_check(staged: bool) -> str:
|
||||
if staged:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "show", ":config/init.conf"], cwd=ROOT, text=True
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
sys.exit("check: config/init.conf не в индексе")
|
||||
return open(TEMPLATE, encoding="utf-8").read()
|
||||
|
||||
|
||||
def cmd_check(staged: bool = False) -> None:
|
||||
"""Падает (exit 1), если в шаблоне есть непустой секрет."""
|
||||
data = json.loads(read_template_for_check(staged))
|
||||
found = []
|
||||
for (section, field), key in MAP:
|
||||
value = (data.get(section) or {}).get(field)
|
||||
if value:
|
||||
found.append(f"{section}.{field} ({key}) = {value[:6]}…")
|
||||
if found:
|
||||
sys.exit(
|
||||
"check: ОТКАЗ — в config/init.conf есть секреты, коммитить нельзя:\n "
|
||||
+ "\n ".join(found)
|
||||
+ "\nВыполните: python3 scripts/config-secrets.py export"
|
||||
)
|
||||
print("check: секретов в config/init.conf нет — ок")
|
||||
|
||||
|
||||
def cmd_status() -> None:
|
||||
tpl = load_json(TEMPLATE)
|
||||
live = load_json(LIVE) if os.path.exists(LIVE) else None
|
||||
print(f"{'путь':28} {'есть?' :6} секреты")
|
||||
for (section, field), key in MAP:
|
||||
t = (tpl.get(section) or {}).get(field)
|
||||
l = (live.get(section) or {}).get(field) if live else None
|
||||
print(
|
||||
f"{section}.{field:24} шаблон={'заполнен' if t else 'пусто':8} "
|
||||
f"боевой={'заполнен' if l else ('нет файла' if live is None else 'пусто')}"
|
||||
)
|
||||
print(f"secrets.env: {'есть' if os.path.exists(SECRETS) else 'НЕТ'}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
cmd = args[0] if args else "status"
|
||||
if cmd == "apply":
|
||||
cmd_apply()
|
||||
elif cmd == "export":
|
||||
cmd_export()
|
||||
elif cmd == "check":
|
||||
cmd_check(staged="--staged" in args)
|
||||
elif cmd == "status":
|
||||
cmd_status()
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Вешает git-хуки, которые держат секреты вне git.
|
||||
# post-merge / post-checkout -> apply (собирает config/init.custom.conf)
|
||||
# pre-commit -> check (отменяет коммит с секретами)
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
mkdir -p .git/hooks
|
||||
|
||||
cat > .git/hooks/post-merge <<'H'
|
||||
#!/bin/sh
|
||||
exec python3 scripts/config-secrets.py apply
|
||||
H
|
||||
|
||||
cat > .git/hooks/post-checkout <<'H'
|
||||
#!/bin/sh
|
||||
exec python3 scripts/config-secrets.py apply
|
||||
H
|
||||
|
||||
cat > .git/hooks/pre-commit <<'H'
|
||||
#!/bin/sh
|
||||
exec python3 scripts/config-secrets.py check --staged
|
||||
H
|
||||
|
||||
chmod +x .git/hooks/post-merge .git/hooks/post-checkout .git/hooks/pre-commit
|
||||
echo "хуки установлены:"
|
||||
ls -l .git/hooks/post-merge .git/hooks/post-checkout .git/hooks/pre-commit
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Восстанавливает веб-клиент Lampa (wwwroot/lampa-main), который LampaCron
|
||||
# берёт с github.com и который пропадает при пересоздании контейнера
|
||||
# (нет в образе, живёт только в writable-слое).
|
||||
#
|
||||
# bash scripts/restore-lampa-main.sh scripts/lampa-main.zip
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
ZIP="${1:-scripts/lampa-main.zip}"
|
||||
if [ ! -f "$ZIP" ]; then
|
||||
echo "нет $ZIP — берите архив с https://github.com/yumata/lampa/archive/refs/heads/main.zip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec lampac sh -c "rm -rf /lampac/wwwroot/lampa-main /lampac/wwwroot/lampa.zip"
|
||||
docker cp "$ZIP" lampac:/lampac/wwwroot/lampa.zip
|
||||
|
||||
docker exec lampac sh -c '
|
||||
cd /lampac/wwwroot
|
||||
unzip -q -o lampa-main.zip
|
||||
mv lampa-main-main lampa-main 2>/dev/null || mv lampa-main lampa-main 2>/dev/null || true
|
||||
chown -R 1000:1000 lampa-main
|
||||
rm -f lampa.zip
|
||||
# инъекция lampainit.js (делает LampaCron при следующем старте)
|
||||
python3 - <<PY
|
||||
import os
|
||||
p="/lampac/wwwroot/lampa-main/index.html"
|
||||
html=open(p,encoding="utf-8").read()
|
||||
if "src=\"/lampainit.js\"" not in html:
|
||||
html=html.replace("</body>", "<script src=\"/lampainit.js\"></script></body>")
|
||||
open(p,"w",encoding="utf-8").write(html)
|
||||
print("index.html: lampainit.js внедрён")
|
||||
else:
|
||||
print("index.html: уже был")
|
||||
PY
|
||||
'
|
||||
|
||||
echo "lampa-main восстановлен ($(docker exec lampac sh -c 'find /lampac/wwwroot/lampa-main -type f | wc -l') файлов)"
|
||||
Reference in New Issue
Block a user