Features: feed CRUD, per-feed ntfy target (incl. private servers), Telegram/webhook channels, keyword filters, image attachments, per-feed intervals, OPML import/export, notification history & stats, users with roles, admin alerts, RU/EN i18n, light/dark theme, notification preview, history search, activity chart. Dockerized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
README.md
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Copy to .env and adjust. All values are optional — sane defaults apply.
|
||||||
|
|
||||||
|
# Default ntfy server used by feeds that don't specify their own.
|
||||||
|
DEFAULT_NTFY_SERVER=https://ntfy.sh
|
||||||
|
|
||||||
|
# How often (minutes) feeds are polled. Editable later in the UI.
|
||||||
|
DEFAULT_CHECK_INTERVAL=5
|
||||||
|
|
||||||
|
# Bootstrap admin account — used ONLY when the database is first created.
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
# Cookie signing secret. Leave empty to auto-generate & persist in DATA_DIR.
|
||||||
|
# SECRET_KEY=
|
||||||
|
|
||||||
|
# Where the SQLite DB and secret key are stored.
|
||||||
|
DATA_DIR=./data
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Normalize all text files to LF in the repo (matters for Docker/shell).
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.ico binary
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Optional Gitea Actions pipeline: build the Docker image and push it to the
|
||||||
|
# Gitea Container Registry on every push to the main branch.
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# * A Gitea Actions runner (act_runner) registered with this instance.
|
||||||
|
# * The registry host (e.g. 192.168.1.171:3000) added as an *insecure registry*
|
||||||
|
# in the runner's Docker daemon (/etc/docker/daemon.json), because it is
|
||||||
|
# served over plain HTTP on a custom port:
|
||||||
|
# { "insecure-registries": ["192.168.1.171:3000"] }
|
||||||
|
#
|
||||||
|
# After a successful run the image is available as:
|
||||||
|
# 192.168.1.171:3000/<owner>/rss-ntfy:latest
|
||||||
|
name: build-and-push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY_HOST }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ github.repository }}:latest
|
||||||
|
${{ vars.REGISTRY_HOST }}/${{ github.repository }}:${{ github.sha }}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.claude/
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# 🚀 Развёртывание в Docker через Gitea
|
||||||
|
|
||||||
|
Проект хранится в Gitea (`http://192.168.1.171:3000`). Ниже — как поднять его в
|
||||||
|
Docker на удалённом локальном хосте (в той же сети). Замените `<OWNER>` на имя
|
||||||
|
вашего пользователя/организации в Gitea.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вариант A. Клонирование + Docker Compose (рекомендуется)
|
||||||
|
|
||||||
|
Самый простой путь — собрать образ прямо на целевом хосте из исходников.
|
||||||
|
|
||||||
|
На удалённом хосте (Linux с установленными `git`, `docker`, `docker compose`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Склонировать репозиторий из Gitea
|
||||||
|
git clone http://192.168.1.171:3000/<OWNER>/rss-ntfy.git
|
||||||
|
cd rss-ntfy
|
||||||
|
|
||||||
|
# 2. (опционально) задать свои параметры
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # смените ADMIN_PASSWORD и т.д.
|
||||||
|
|
||||||
|
# 3. Собрать и запустить
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 4. Проверить
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Панель откроется на `http://<IP_хоста>:8000`.
|
||||||
|
|
||||||
|
> Приватный репозиторий? Используйте токен в URL:
|
||||||
|
> `git clone http://<OWNER>:<TOKEN>@192.168.1.171:3000/<OWNER>/rss-ntfy.git`
|
||||||
|
|
||||||
|
### Обновление до новой версии
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rss-ntfy
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
База данных лежит в Docker-томе `rss_ntfy_data` и переживает пересборку.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вариант B. Готовый образ из Gitea Container Registry
|
||||||
|
|
||||||
|
Если настроен Gitea Actions-раннер, пайплайн `.gitea/workflows/docker.yml`
|
||||||
|
сам собирает образ и публикует его в реестр Gitea при каждом пуше в `main`.
|
||||||
|
|
||||||
|
### Однократная настройка на стороне Gitea
|
||||||
|
1. **Включить Actions**: Settings → Actions, и зарегистрировать раннер
|
||||||
|
(`act_runner`).
|
||||||
|
2. В репозитории задать переменную **`REGISTRY_HOST`** = `192.168.1.171:3000`
|
||||||
|
(Settings → Actions → Variables).
|
||||||
|
3. На хосте раннера разрешить незащищённый реестр (HTTP) — в
|
||||||
|
`/etc/docker/daemon.json`:
|
||||||
|
```json
|
||||||
|
{ "insecure-registries": ["192.168.1.171:3000"] }
|
||||||
|
```
|
||||||
|
затем `systemctl restart docker`.
|
||||||
|
|
||||||
|
### Запуск из готового образа на целевом хосте
|
||||||
|
|
||||||
|
Тоже разрешите insecure-registry (см. выше), затем:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# вход в реестр Gitea (логин Gitea + токен как пароль)
|
||||||
|
docker login 192.168.1.171:3000
|
||||||
|
|
||||||
|
# тянем и запускаем образ
|
||||||
|
docker pull 192.168.1.171:3000/<OWNER>/rss-ntfy:latest
|
||||||
|
docker run -d --name rss-ntfy --restart unless-stopped \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-v rss_ntfy_data:/data \
|
||||||
|
-e ADMIN_PASSWORD=измените_меня \
|
||||||
|
192.168.1.171:3000/<OWNER>/rss-ntfy:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через Compose — создайте `docker-compose.prod.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
rss-ntfy:
|
||||||
|
image: 192.168.1.171:3000/<OWNER>/rss-ntfy:latest
|
||||||
|
container_name: rss-ntfy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- rss_ntfy_data:/data
|
||||||
|
environment:
|
||||||
|
ADMIN_PASSWORD: "измените_меня"
|
||||||
|
volumes:
|
||||||
|
rss_ntfy_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Частые вопросы
|
||||||
|
|
||||||
|
- **`docker login` ругается на HTTPS** — вы не добавили хост в
|
||||||
|
`insecure-registries` (реестр работает по HTTP на нестандартном порту).
|
||||||
|
- **Порт 8000 занят** — поменяйте левую часть проброса, напр. `-p 9000:8000`.
|
||||||
|
- **Сбросить пароль администратора** — `ADMIN_*` действуют только при первом
|
||||||
|
старте; позже меняйте пароль во вкладке «Пользователи» или удалите том
|
||||||
|
`rss_ntfy_data` для полного сброса.
|
||||||
|
- **Бэкап** — достаточно сохранить том `rss_ntfy_data` (там SQLite-база
|
||||||
|
`app.db` и ключ подписи сессий).
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
DATA_DIR=/data
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first for better layer caching.
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
# Persistent data (SQLite DB + secret key) lives here.
|
||||||
|
RUN mkdir -p /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/login').status<500 else 1)" || exit 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 📡 RSS → ntfy
|
||||||
|
|
||||||
|
Лёгкое приложение на Python (FastAPI), которое следит за RSS/Atom-лентами и при
|
||||||
|
появлении новых записей рассылает их в [ntfy](https://ntfy.sh), Telegram и/или
|
||||||
|
через webhook. Управление — через современную веб-панель.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
**Основное**
|
||||||
|
- ✅ Добавление, редактирование и удаление RSS-лент через веб-интерфейс
|
||||||
|
- ✅ Свой ntfy-сервер и тема для каждой ленты (или общий по умолчанию)
|
||||||
|
- ✅ Приоритет и теги/эмодзи для каждой ленты
|
||||||
|
- ✅ Опциональная авторизация при входе в веб-панель (вкл/выкл из UI)
|
||||||
|
- ✅ Настраиваемый интервал проверки и кнопки «Проверить сейчас» / «тест»
|
||||||
|
- ✅ Защита от дублей: при первом добавлении ленты история не рассылается
|
||||||
|
|
||||||
|
**Расширенные возможности**
|
||||||
|
- 🔐 **Приватные ntfy-серверы** — Bearer-токен или Basic-авторизация на ленту
|
||||||
|
- ✈️ **Telegram** — дублирование уведомлений через бота (вкл. на нужных лентах)
|
||||||
|
- 🔗 **Webhook** — POST с JSON в произвольный URL как ещё один канал
|
||||||
|
- 🖼️ **Картинки** — первое изображение записи прикрепляется к ntfy-уведомлению
|
||||||
|
- 🧩 **Фильтры по ключевым словам** — include/exclude на каждую ленту
|
||||||
|
- ⏱️ **Индивидуальный интервал** проверки для каждой ленты (0 = общий)
|
||||||
|
- 📊 **История и статистика** — лог всех отправок (успех/ошибка) + сводка
|
||||||
|
- 👥 **Несколько пользователей и роли** — `admin` (полный доступ) и `viewer`
|
||||||
|
- 🩺 **Алерты администратора** — ntfy-уведомление, если лента падает N раз подряд
|
||||||
|
- 🔁 **Импорт/экспорт OPML** — перенос списка лент из/в другие ридеры
|
||||||
|
|
||||||
|
**Интерфейс**
|
||||||
|
- 🌗 **Светлая и тёмная тема** — переключатель, выбор запоминается
|
||||||
|
- 🌍 **Локализация RU / EN** — переключение языка на лету
|
||||||
|
- 👁 **Предпросмотр уведомления** — как будет выглядеть последняя запись ленты
|
||||||
|
- 🔍 **Поиск по истории** + фильтр «только ошибки»
|
||||||
|
- 📈 **График активности** за 14 дней (отправлено / сбои)
|
||||||
|
|
||||||
|
Готов к запуску в Docker, данные хранятся в томе. Внешняя БД не нужна (SQLite).
|
||||||
|
Инструкция по развёртыванию через Gitea — в [DEPLOY.md](DEPLOY.md).
|
||||||
|
|
||||||
|
## Быстрый старт (Docker Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте **http://localhost:8000**. Логин/пароль по умолчанию — `admin` / `admin`
|
||||||
|
(вход требуется только если включить авторизацию в настройках).
|
||||||
|
|
||||||
|
> ⚠️ Поменяйте `ADMIN_USERNAME` / `ADMIN_PASSWORD` в `docker-compose.yml`
|
||||||
|
> **до первого запуска**, либо смените пароль во вкладке «Пользователи».
|
||||||
|
> Эти переменные применяются только при создании базы данных.
|
||||||
|
|
||||||
|
## Запуск без Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как пользоваться
|
||||||
|
|
||||||
|
1. **Настройки → ntfy**: укажите сервер по умолчанию и отправьте тест.
|
||||||
|
2. **Ленты → Добавить ленту**: вставьте URL RSS и тему ntfy (например `my-news`).
|
||||||
|
В расширенном блоке можно задать токен приватного сервера, фильтры по словам,
|
||||||
|
личный интервал и включить дублирование в Telegram/webhook.
|
||||||
|
3. Подпишитесь на тему в приложении ntfy или на `https://ntfy.sh/my-news`.
|
||||||
|
4. **История** — журнал отправленных и неудачных уведомлений.
|
||||||
|
5. **Пользователи** — добавьте учётки с ролями (нужно для включения авторизации).
|
||||||
|
6. **Настройки → Авторизация** — включите требование входа в панель.
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
1. Создайте бота через [@BotFather](https://t.me/BotFather), получите токен.
|
||||||
|
2. Добавьте бота в чат/канал и узнайте `chat_id`
|
||||||
|
(например, через [@userinfobot](https://t.me/userinfobot) или `getUpdates`).
|
||||||
|
3. **Настройки → Telegram**: включите канал, вставьте токен и `chat_id`.
|
||||||
|
4. В нужных лентах поставьте галочку «Дублировать в Telegram».
|
||||||
|
|
||||||
|
### Webhook
|
||||||
|
**Настройки → Webhook** → включите и укажите URL. На каждую новую запись
|
||||||
|
придёт `POST` с JSON:
|
||||||
|
```json
|
||||||
|
{ "feed": "...", "feed_url": "...", "title": "...", "body": "...", "link": "...", "image": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация (переменные окружения)
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `DEFAULT_NTFY_SERVER` | `https://ntfy.sh` | Сервер для лент без своего |
|
||||||
|
| `DEFAULT_CHECK_INTERVAL` | `5` | Интервал проверки по умолчанию, минуты |
|
||||||
|
| `ADMIN_USERNAME` | `admin` | Логин админа (только при первом старте) |
|
||||||
|
| `ADMIN_PASSWORD` | `admin` | Пароль админа (только при первом старте) |
|
||||||
|
| `SECRET_KEY` | автогенерация | Секрет для подписи cookie сессии |
|
||||||
|
| `DATA_DIR` | `./data` (`/data` в Docker) | Где лежит БД и ключ |
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── main.py # FastAPI: страницы, JSON API, авторизация, роли
|
||||||
|
├── models.py # таблицы SQLModel (Feed, SeenEntry, Notification, User, Settings)
|
||||||
|
├── database.py # движок БД, инициализация, авто-миграция колонок
|
||||||
|
├── checker.py # парсинг лент, фильтры, картинки, история, алерты
|
||||||
|
├── delivery.py # доставка по каналам (ntfy + Telegram + webhook)
|
||||||
|
├── ntfy.py # публикация в ntfy (авторизация, вложения)
|
||||||
|
├── scheduler.py # тик раз в минуту, интервалы считаются на лету
|
||||||
|
├── opml.py # импорт/экспорт OPML
|
||||||
|
├── auth.py # хеширование пароля (PBKDF2, stdlib)
|
||||||
|
├── schemas.py # валидация запросов API
|
||||||
|
├── templates/ # Jinja2 (index, login, base)
|
||||||
|
└── static/ # style.css, app.js, i18n.js (RU/EN словари)
|
||||||
|
```
|
||||||
|
|
||||||
|
Хранилище — SQLite (один файл в `DATA_DIR`). При добавлении новых полей в моделях
|
||||||
|
схема существующей БД обновляется автоматически (`ALTER TABLE ... ADD COLUMN`).
|
||||||
|
|
||||||
|
## Идеи для дальнейшего развития
|
||||||
|
|
||||||
|
- 📨 Доставка по e-mail (SMTP) как ещё один канал
|
||||||
|
- 🔑 OAuth/OIDC для входа в больших инсталляциях
|
||||||
|
- 📊 Детализация графиков по конкретной ленте
|
||||||
|
- 🏷️ Группы/папки лент и массовые операции
|
||||||
|
- 🌐 Поддержка прокси для доступа к лентам
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT — используйте свободно.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""RSS → ntfy bridge application."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
"""Password hashing and session helpers.
|
||||||
|
|
||||||
|
Uses stdlib PBKDF2 so no native build dependencies are required.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
_ALGO = "sha256"
|
||||||
|
_ITERATIONS = 240_000
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
digest = hashlib.pbkdf2_hmac(
|
||||||
|
_ALGO, password.encode(), bytes.fromhex(salt), _ITERATIONS
|
||||||
|
).hex()
|
||||||
|
return f"pbkdf2_{_ALGO}${_ITERATIONS}${salt}${digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
try:
|
||||||
|
scheme, iterations, salt, digest = stored.split("$")
|
||||||
|
if not scheme.startswith("pbkdf2_"):
|
||||||
|
return False
|
||||||
|
algo = scheme.split("_", 1)[1]
|
||||||
|
expected = hashlib.pbkdf2_hmac(
|
||||||
|
algo, password.encode(), bytes.fromhex(salt), int(iterations)
|
||||||
|
).hex()
|
||||||
|
return hmac.compare_digest(expected, digest)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
"""Background RSS polling and dispatch across channels."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from html import unescape
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from . import delivery
|
||||||
|
from .database import engine, get_settings
|
||||||
|
from .delivery import Message
|
||||||
|
from .models import Feed, Notification, SeenEntry
|
||||||
|
|
||||||
|
log = logging.getLogger("checker")
|
||||||
|
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
_IMG_RE = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_html(text: str, limit: int = 1500) -> str:
|
||||||
|
text = unescape(_TAG_RE.sub(" ", text or ""))
|
||||||
|
text = re.sub(r"[ \t]+", " ", text)
|
||||||
|
text = re.sub(r"\n\s*\n\s*\n+", "\n\n", text).strip()
|
||||||
|
if len(text) > limit:
|
||||||
|
text = text[:limit].rsplit(" ", 1)[0] + " …"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_uid(entry) -> str:
|
||||||
|
for key in ("id", "guid", "link"):
|
||||||
|
value = entry.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
return f"{entry.get('title', '')}|{entry.get('published', '')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_image(entry) -> str:
|
||||||
|
"""Best-effort: find an image URL in media tags, enclosures or HTML."""
|
||||||
|
media = entry.get("media_content") or entry.get("media_thumbnail")
|
||||||
|
if media and isinstance(media, list):
|
||||||
|
url = media[0].get("url")
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
for link in entry.get("links", []):
|
||||||
|
if link.get("rel") == "enclosure" and str(link.get("type", "")).startswith("image"):
|
||||||
|
return link.get("href", "")
|
||||||
|
html = entry.get("summary") or entry.get("description") or ""
|
||||||
|
if not html:
|
||||||
|
content = entry.get("content")
|
||||||
|
if content and isinstance(content, list):
|
||||||
|
html = content[0].get("value", "")
|
||||||
|
match = _IMG_RE.search(html or "")
|
||||||
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _passes_filters(feed: Feed, title: str, body: str) -> bool:
|
||||||
|
"""Keyword include/exclude check (case-insensitive)."""
|
||||||
|
haystack = f"{title}\n{body}".lower()
|
||||||
|
includes = [k.strip().lower() for k in feed.filter_include.split(",") if k.strip()]
|
||||||
|
excludes = [k.strip().lower() for k in feed.filter_exclude.split(",") if k.strip()]
|
||||||
|
if includes and not any(k in haystack for k in includes):
|
||||||
|
return False
|
||||||
|
if excludes and any(k in haystack for k in excludes):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(url: str):
|
||||||
|
"""Blocking feedparser call (run in a thread)."""
|
||||||
|
return feedparser.parse(url, agent="rss-ntfy/1.0 (+https://github.com)")
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_preview(url: str, include: str = "", exclude: str = "") -> dict:
|
||||||
|
"""Fetch a feed and return the newest entry passing filters, for previewing.
|
||||||
|
|
||||||
|
Raises ValueError if the feed can't be parsed or has no matching entries.
|
||||||
|
"""
|
||||||
|
parsed = await asyncio.to_thread(_parse, url)
|
||||||
|
if getattr(parsed, "bozo", False) and not parsed.entries:
|
||||||
|
raise ValueError(str(getattr(parsed, "bozo_exception", "parse error")))
|
||||||
|
if not parsed.entries:
|
||||||
|
raise ValueError("no entries")
|
||||||
|
|
||||||
|
probe = Feed(url=url, filter_include=include, filter_exclude=exclude)
|
||||||
|
feed_title = parsed.feed.get("title", "") if parsed.feed else ""
|
||||||
|
for entry in parsed.entries:
|
||||||
|
title = entry.get("title", "")
|
||||||
|
body = _strip_html(entry.get("summary") or entry.get("description") or "")
|
||||||
|
if not _passes_filters(probe, title, body):
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"source": feed_title,
|
||||||
|
"title": title or "(no title)",
|
||||||
|
"body": body,
|
||||||
|
"image": _extract_image(entry),
|
||||||
|
"link": entry.get("link", ""),
|
||||||
|
}
|
||||||
|
raise ValueError("no entries match the filters")
|
||||||
|
|
||||||
|
|
||||||
|
async def check_feed(feed: Feed) -> str:
|
||||||
|
"""Check a single feed, dispatch new entries, log history. Returns status."""
|
||||||
|
parsed = await asyncio.to_thread(_parse, feed.url)
|
||||||
|
|
||||||
|
if getattr(parsed, "bozo", False) and not parsed.entries:
|
||||||
|
exc = getattr(parsed, "bozo_exception", "parse error")
|
||||||
|
status = f"parse_error:{exc}"
|
||||||
|
await _record_failure(feed.id, status)
|
||||||
|
return status
|
||||||
|
|
||||||
|
feed_title = parsed.feed.get("title", "") if parsed.feed else ""
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
settings = get_settings(session)
|
||||||
|
db_feed = session.get(Feed, feed.id)
|
||||||
|
if db_feed is None:
|
||||||
|
return "Лента удалена"
|
||||||
|
|
||||||
|
if feed_title and not db_feed.title:
|
||||||
|
db_feed.title = feed_title
|
||||||
|
|
||||||
|
seen_uids = set(
|
||||||
|
session.exec(
|
||||||
|
select(SeenEntry.entry_uid).where(SeenEntry.feed_id == feed.id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
first_run = len(seen_uids) == 0
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
skipped = 0
|
||||||
|
# Oldest first so notifications arrive in chronological order.
|
||||||
|
for entry in reversed(parsed.entries):
|
||||||
|
uid = _entry_uid(entry)
|
||||||
|
if uid in seen_uids:
|
||||||
|
continue
|
||||||
|
seen_uids.add(uid)
|
||||||
|
session.add(SeenEntry(feed_id=feed.id, entry_uid=uid))
|
||||||
|
|
||||||
|
# On the very first check we only record state, never spam history.
|
||||||
|
if first_run:
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = entry.get("title", "(без заголовка)")
|
||||||
|
body = _strip_html(entry.get("summary") or entry.get("description") or "")
|
||||||
|
|
||||||
|
if not _passes_filters(db_feed, title, body):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
source=db_feed.title or feed_title,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
link=entry.get("link", ""),
|
||||||
|
image=_extract_image(entry),
|
||||||
|
)
|
||||||
|
result = await delivery.dispatch(db_feed, settings, msg)
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
Notification(
|
||||||
|
feed_id=db_feed.id,
|
||||||
|
feed_title=msg.source,
|
||||||
|
title=title,
|
||||||
|
link=msg.link,
|
||||||
|
channels=",".join(result.channels),
|
||||||
|
ok=result.ok,
|
||||||
|
detail=result.detail,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.ok:
|
||||||
|
sent += 1
|
||||||
|
elif not result.channels:
|
||||||
|
# Hard failure (e.g. ntfy unreachable) — surface it and stop.
|
||||||
|
db_feed.last_checked = datetime.now(timezone.utc)
|
||||||
|
db_feed.last_status = f"send_error:{result.detail}"
|
||||||
|
db_feed.error_streak += 1
|
||||||
|
session.commit()
|
||||||
|
await _maybe_alert(db_feed.id)
|
||||||
|
return db_feed.last_status
|
||||||
|
|
||||||
|
db_feed.last_checked = datetime.now(timezone.utc)
|
||||||
|
db_feed.error_streak = 0
|
||||||
|
if first_run:
|
||||||
|
db_feed.last_status = f"init:{len(seen_uids)}"
|
||||||
|
elif sent:
|
||||||
|
db_feed.last_status = f"sent:{sent}:{skipped}" if skipped else f"sent:{sent}"
|
||||||
|
elif skipped:
|
||||||
|
db_feed.last_status = f"filtered:{skipped}"
|
||||||
|
else:
|
||||||
|
db_feed.last_status = "nochange"
|
||||||
|
session.commit()
|
||||||
|
return db_feed.last_status
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_failure(feed_id: int, status: str) -> None:
|
||||||
|
with Session(engine) as session:
|
||||||
|
db_feed = session.get(Feed, feed_id)
|
||||||
|
if db_feed is None:
|
||||||
|
return
|
||||||
|
db_feed.last_checked = datetime.now(timezone.utc)
|
||||||
|
db_feed.last_status = status
|
||||||
|
db_feed.error_streak += 1
|
||||||
|
session.commit()
|
||||||
|
await _maybe_alert(feed_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_alert(feed_id: int) -> None:
|
||||||
|
"""Send an admin alert if a feed has failed too many times in a row."""
|
||||||
|
with Session(engine) as session:
|
||||||
|
settings = get_settings(session)
|
||||||
|
db_feed = session.get(Feed, feed_id)
|
||||||
|
if db_feed is None or not settings.alerts_enabled:
|
||||||
|
return
|
||||||
|
# Alert once, exactly when the streak crosses the threshold.
|
||||||
|
if db_feed.error_streak == settings.alert_threshold:
|
||||||
|
text = (
|
||||||
|
f"Feed \"{db_feed.title or db_feed.url}\" is failing "
|
||||||
|
f"({db_feed.error_streak} consecutive errors)."
|
||||||
|
)
|
||||||
|
await delivery.send_admin_alert(settings, text)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_all_feeds() -> None:
|
||||||
|
"""Check feeds whose per-feed interval has elapsed (1-minute tick)."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
settings = get_settings(session)
|
||||||
|
feeds = session.exec(select(Feed).where(Feed.enabled == True)).all() # noqa: E712
|
||||||
|
default_interval = settings.check_interval
|
||||||
|
|
||||||
|
due: list[Feed] = []
|
||||||
|
for feed in feeds:
|
||||||
|
interval = feed.interval if feed.interval and feed.interval > 0 else default_interval
|
||||||
|
if feed.last_checked is None:
|
||||||
|
due.append(feed)
|
||||||
|
continue
|
||||||
|
last = feed.last_checked
|
||||||
|
if last.tzinfo is None:
|
||||||
|
last = last.replace(tzinfo=timezone.utc)
|
||||||
|
if (now - last).total_seconds() >= interval * 60:
|
||||||
|
due.append(feed)
|
||||||
|
|
||||||
|
if not due:
|
||||||
|
return
|
||||||
|
log.info("Проверка %d из %d лент", len(due), len(feeds))
|
||||||
|
for feed in due:
|
||||||
|
try:
|
||||||
|
await check_feed(feed)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.exception("Ошибка проверки ленты %s: %s", feed.url, exc)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Application configuration loaded from environment variables."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Where persistent data (SQLite DB) lives. Mounted as a volume in Docker.
|
||||||
|
DATA_DIR = Path(os.getenv("DATA_DIR", "./data")).resolve()
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR / 'app.db'}")
|
||||||
|
|
||||||
|
# Secret used to sign session cookies. Generate a stable one if not provided,
|
||||||
|
# persisting it to disk so sessions survive restarts.
|
||||||
|
_SECRET_FILE = DATA_DIR / "secret.key"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_secret() -> str:
|
||||||
|
env = os.getenv("SECRET_KEY")
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
if _SECRET_FILE.exists():
|
||||||
|
return _SECRET_FILE.read_text().strip()
|
||||||
|
value = secrets.token_hex(32)
|
||||||
|
_SECRET_FILE.write_text(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_KEY = _load_secret()
|
||||||
|
|
||||||
|
# Defaults used the first time the app starts (before any settings are saved).
|
||||||
|
DEFAULT_NTFY_SERVER = os.getenv("DEFAULT_NTFY_SERVER", "https://ntfy.sh")
|
||||||
|
DEFAULT_CHECK_INTERVAL = int(os.getenv("DEFAULT_CHECK_INTERVAL", "5")) # minutes
|
||||||
|
|
||||||
|
# Bootstrap admin credentials (only applied when the settings row is created).
|
||||||
|
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||||
|
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
"""Database engine, session helpers, bootstrap and lightweight migration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine, select
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .auth import hash_password
|
||||||
|
from .models import Settings, User
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
config.DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate() -> None:
|
||||||
|
"""Add any model columns missing from existing tables (SQLite ALTER ADD).
|
||||||
|
|
||||||
|
Keeps simple deployments upgradeable without a migration framework.
|
||||||
|
New columns always have defaults, so a plain ADD COLUMN is sufficient.
|
||||||
|
"""
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = set(inspector.get_table_names())
|
||||||
|
type_map = {"INTEGER": "INTEGER", "BOOLEAN": "BOOLEAN", "VARCHAR": "VARCHAR", "DATETIME": "DATETIME"}
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for table in SQLModel.metadata.sorted_tables:
|
||||||
|
if table.name not in existing_tables:
|
||||||
|
continue
|
||||||
|
have = {c["name"] for c in inspector.get_columns(table.name)}
|
||||||
|
for column in table.columns:
|
||||||
|
if column.name in have:
|
||||||
|
continue
|
||||||
|
col_type = type_map.get(
|
||||||
|
column.type.__class__.__name__.upper(), "VARCHAR"
|
||||||
|
)
|
||||||
|
default = column.default.arg if column.default is not None else None
|
||||||
|
if isinstance(default, bool):
|
||||||
|
default_sql = "1" if default else "0"
|
||||||
|
elif isinstance(default, (int, float)):
|
||||||
|
default_sql = str(default)
|
||||||
|
elif isinstance(default, str):
|
||||||
|
default_sql = f"'{default}'"
|
||||||
|
else:
|
||||||
|
default_sql = "NULL"
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
f'ALTER TABLE "{table.name}" '
|
||||||
|
f'ADD COLUMN "{column.name}" {col_type} DEFAULT {default_sql}'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
"""Create tables, run migration, ensure settings + admin user exist."""
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
_migrate()
|
||||||
|
with Session(engine) as session:
|
||||||
|
if session.get(Settings, 1) is None:
|
||||||
|
session.add(
|
||||||
|
Settings(
|
||||||
|
id=1,
|
||||||
|
default_ntfy_server=config.DEFAULT_NTFY_SERVER,
|
||||||
|
check_interval=config.DEFAULT_CHECK_INTERVAL,
|
||||||
|
auth_enabled=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Bootstrap the first admin account if no users exist.
|
||||||
|
if not session.exec(select(User)).first():
|
||||||
|
session.add(
|
||||||
|
User(
|
||||||
|
username=config.ADMIN_USERNAME,
|
||||||
|
password_hash=hash_password(config.ADMIN_PASSWORD),
|
||||||
|
role="admin",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings(session: Session) -> Settings:
|
||||||
|
settings = session.get(Settings, 1)
|
||||||
|
if settings is None: # safety net
|
||||||
|
settings = Settings(id=1)
|
||||||
|
session.add(settings)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope() -> Iterator[Session]:
|
||||||
|
session = Session(engine)
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_session() -> Iterator[Session]:
|
||||||
|
"""FastAPI dependency."""
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"engine",
|
||||||
|
"init_db",
|
||||||
|
"get_settings",
|
||||||
|
"get_session",
|
||||||
|
"session_scope",
|
||||||
|
"select",
|
||||||
|
]
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
"""Alternative delivery channels and a unified dispatcher.
|
||||||
|
|
||||||
|
A single feed entry can fan out to several channels: ntfy (always, if a topic
|
||||||
|
is set), Telegram, and a generic webhook. Each channel is independent — one
|
||||||
|
failing does not block the others. dispatch() returns which channels succeeded
|
||||||
|
and an error string describing any failures (for the history log).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from . import ntfy
|
||||||
|
from .models import Feed, Settings
|
||||||
|
|
||||||
|
log = logging.getLogger("delivery")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Message:
|
||||||
|
source: str # feed title
|
||||||
|
title: str # entry title
|
||||||
|
body: str # plain-text summary
|
||||||
|
link: str = ""
|
||||||
|
image: str = "" # image URL, if any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DispatchResult:
|
||||||
|
channels: list[str] = field(default_factory=list) # succeeded channels
|
||||||
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return not self.errors and bool(self.channels)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def detail(self) -> str:
|
||||||
|
return "; ".join(self.errors)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_telegram(settings: Settings, msg: Message) -> None:
|
||||||
|
token = settings.telegram_token.strip()
|
||||||
|
chat_id = settings.telegram_chat_id.strip()
|
||||||
|
if not token or not chat_id:
|
||||||
|
raise ValueError("Telegram не настроен (токен/chat_id)")
|
||||||
|
|
||||||
|
text = f"<b>{_esc(msg.title)}</b>"
|
||||||
|
if msg.source:
|
||||||
|
text = f"📡 <i>{_esc(msg.source)}</i>\n{text}"
|
||||||
|
if msg.body:
|
||||||
|
text += f"\n\n{_esc(msg.body[:600])}"
|
||||||
|
if msg.link:
|
||||||
|
text += f'\n\n<a href="{_esc(msg.link)}">Открыть →</a>'
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"https://api.telegram.org/bot{token}/sendMessage",
|
||||||
|
json={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_webhook(settings: Settings, feed: Feed, msg: Message) -> None:
|
||||||
|
url = settings.webhook_url.strip()
|
||||||
|
if not url:
|
||||||
|
raise ValueError("Webhook URL не задан")
|
||||||
|
payload = {
|
||||||
|
"feed": msg.source,
|
||||||
|
"feed_url": feed.url,
|
||||||
|
"title": msg.title,
|
||||||
|
"body": msg.body,
|
||||||
|
"link": msg.link,
|
||||||
|
"image": msg.image,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
resp = await client.post(url, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(text: str) -> str:
|
||||||
|
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResult:
|
||||||
|
"""Send a message across every channel enabled for this feed."""
|
||||||
|
result = DispatchResult()
|
||||||
|
server = feed.ntfy_server.strip() or settings.default_ntfy_server
|
||||||
|
full_title = f"{msg.source}: {msg.title}" if msg.source else msg.title
|
||||||
|
|
||||||
|
# --- ntfy (default channel; requires a topic) ---
|
||||||
|
if feed.ntfy_topic.strip():
|
||||||
|
try:
|
||||||
|
await ntfy.publish(
|
||||||
|
server=server,
|
||||||
|
topic=feed.ntfy_topic,
|
||||||
|
title=full_title,
|
||||||
|
message=msg.body or "(нет описания)",
|
||||||
|
click=msg.link,
|
||||||
|
tags=feed.tags,
|
||||||
|
priority=feed.priority,
|
||||||
|
attach=msg.image if feed.attach_image else "",
|
||||||
|
token=feed.ntfy_token,
|
||||||
|
username=feed.ntfy_username,
|
||||||
|
password=feed.ntfy_password,
|
||||||
|
)
|
||||||
|
result.channels.append("ntfy")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
result.errors.append(f"ntfy: {exc}")
|
||||||
|
|
||||||
|
# --- Telegram ---
|
||||||
|
if feed.to_telegram and settings.telegram_enabled:
|
||||||
|
try:
|
||||||
|
await _send_telegram(settings, msg)
|
||||||
|
result.channels.append("telegram")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
result.errors.append(f"telegram: {exc}")
|
||||||
|
|
||||||
|
# --- Webhook ---
|
||||||
|
if feed.to_webhook and settings.webhook_enabled:
|
||||||
|
try:
|
||||||
|
await _send_webhook(settings, feed, msg)
|
||||||
|
result.channels.append("webhook")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
result.errors.append(f"webhook: {exc}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def send_admin_alert(settings: Settings, text: str) -> None:
|
||||||
|
"""Best-effort health alert to the admin ntfy topic."""
|
||||||
|
if not settings.alerts_enabled or not settings.alert_topic.strip():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await ntfy.publish(
|
||||||
|
server=settings.default_ntfy_server,
|
||||||
|
topic=settings.alert_topic,
|
||||||
|
title="RSS to ntfy — alert",
|
||||||
|
message=text,
|
||||||
|
tags="warning",
|
||||||
|
priority=4,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("admin alert failed: %s", exc)
|
||||||
+539
@@ -0,0 +1,539 @@
|
|||||||
|
"""FastAPI application: web UI + JSON API for the RSS → ntfy bridge."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import (
|
||||||
|
HTMLResponse,
|
||||||
|
JSONResponse,
|
||||||
|
PlainTextResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import Integer
|
||||||
|
from sqlmodel import Session, func, select
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from . import config, ntfy, opml, scheduler
|
||||||
|
from .auth import hash_password, verify_password
|
||||||
|
from .checker import check_feed, fetch_preview
|
||||||
|
from .database import engine, get_session, get_settings, init_db
|
||||||
|
from .models import Feed, Notification, SeenEntry, User
|
||||||
|
from .schemas import FeedIn, PreviewIn, SettingsIn, TestIn, UserIn
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("app")
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
init_db()
|
||||||
|
with Session(engine) as session:
|
||||||
|
interval = get_settings(session).check_interval
|
||||||
|
scheduler.start(interval)
|
||||||
|
log.info("Приложение запущено")
|
||||||
|
yield
|
||||||
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="RSS → ntfy", lifespan=lifespan)
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14
|
||||||
|
)
|
||||||
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Auth helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _current_user(request: Request, session: Session) -> User | None:
|
||||||
|
uid = request.session.get("uid")
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
return session.get(User, uid)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_on(session: Session) -> bool:
|
||||||
|
return get_settings(session).auth_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(request: Request, session: Session = Depends(get_session)) -> User:
|
||||||
|
"""Any logged-in user (or anyone when auth is disabled)."""
|
||||||
|
if not _auth_on(session):
|
||||||
|
# Auth disabled → act as a virtual admin.
|
||||||
|
return User(id=0, username="anonymous", role="admin")
|
||||||
|
user = _current_user(request, session)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(401, "Требуется авторизация")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: User = Depends(require_auth)) -> User:
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(403, "Требуются права администратора")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Pages
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index(request: Request, session: Session = Depends(get_session)):
|
||||||
|
if _auth_on(session) and _current_user(request, session) is None:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
|
def login_page(request: Request, session: Session = Depends(get_session)):
|
||||||
|
if not _auth_on(session) or _current_user(request, session) is not None:
|
||||||
|
return RedirectResponse("/", status_code=302)
|
||||||
|
return templates.TemplateResponse("login.html", {"request": request, "error": None})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login", response_class=HTMLResponse)
|
||||||
|
def login_submit(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
user = session.exec(select(User).where(User.username == username)).first()
|
||||||
|
if user and verify_password(password, user.password_hash):
|
||||||
|
request.session["uid"] = user.id
|
||||||
|
return RedirectResponse("/", status_code=302)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"login.html",
|
||||||
|
{"request": request, "error": "Неверный логин или пароль"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
def logout(request: Request):
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/me")
|
||||||
|
def whoami(
|
||||||
|
request: Request, session: Session = Depends(get_session), user: User = Depends(require_auth)
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"auth_enabled": _auth_on(session),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# API: feeds
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _feed_dict(feed: Feed) -> dict:
|
||||||
|
return {
|
||||||
|
"id": feed.id,
|
||||||
|
"url": feed.url,
|
||||||
|
"title": feed.title,
|
||||||
|
"ntfy_server": feed.ntfy_server,
|
||||||
|
"ntfy_topic": feed.ntfy_topic,
|
||||||
|
"ntfy_token": feed.ntfy_token,
|
||||||
|
"ntfy_username": feed.ntfy_username,
|
||||||
|
"ntfy_password": feed.ntfy_password,
|
||||||
|
"priority": feed.priority,
|
||||||
|
"tags": feed.tags,
|
||||||
|
"attach_image": feed.attach_image,
|
||||||
|
"to_telegram": feed.to_telegram,
|
||||||
|
"to_webhook": feed.to_webhook,
|
||||||
|
"filter_include": feed.filter_include,
|
||||||
|
"filter_exclude": feed.filter_exclude,
|
||||||
|
"interval": feed.interval,
|
||||||
|
"enabled": feed.enabled,
|
||||||
|
"last_checked": feed.last_checked.isoformat() if feed.last_checked else None,
|
||||||
|
"last_status": feed.last_status,
|
||||||
|
"error_streak": feed.error_streak,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/feeds")
|
||||||
|
def list_feeds(session: Session = Depends(get_session), _: User = Depends(require_auth)):
|
||||||
|
feeds = session.exec(select(Feed).order_by(Feed.id)).all()
|
||||||
|
return [_feed_dict(f) for f in feeds]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/feeds")
|
||||||
|
def create_feed(
|
||||||
|
data: FeedIn,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
feed = Feed(**data.model_dump())
|
||||||
|
session.add(feed)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(feed)
|
||||||
|
return _feed_dict(feed)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/feeds/{feed_id}")
|
||||||
|
def update_feed(
|
||||||
|
feed_id: int,
|
||||||
|
data: FeedIn,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
feed = session.get(Feed, feed_id)
|
||||||
|
if feed is None:
|
||||||
|
raise HTTPException(404, "Лента не найдена")
|
||||||
|
for key, value in data.model_dump().items():
|
||||||
|
setattr(feed, key, value)
|
||||||
|
session.add(feed)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(feed)
|
||||||
|
return _feed_dict(feed)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/feeds/{feed_id}")
|
||||||
|
def delete_feed(
|
||||||
|
feed_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
feed = session.get(Feed, feed_id)
|
||||||
|
if feed is None:
|
||||||
|
raise HTTPException(404, "Лента не найдена")
|
||||||
|
for entry in session.exec(select(SeenEntry).where(SeenEntry.feed_id == feed_id)).all():
|
||||||
|
session.delete(entry)
|
||||||
|
for note in session.exec(select(Notification).where(Notification.feed_id == feed_id)).all():
|
||||||
|
session.delete(note)
|
||||||
|
session.delete(feed)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/feeds/{feed_id}/check")
|
||||||
|
async def check_now(
|
||||||
|
feed_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
feed = session.get(Feed, feed_id)
|
||||||
|
if feed is None:
|
||||||
|
raise HTTPException(404, "Лента не найдена")
|
||||||
|
status = await check_feed(feed)
|
||||||
|
session.refresh(feed)
|
||||||
|
return {"status": status, "feed": _feed_dict(feed)}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# API: OPML import / export
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@app.get("/api/feeds/export")
|
||||||
|
def export_feeds(session: Session = Depends(get_session), _: User = Depends(require_auth)):
|
||||||
|
feeds = session.exec(select(Feed).order_by(Feed.id)).all()
|
||||||
|
xml = opml.export_opml(feeds)
|
||||||
|
return Response(
|
||||||
|
xml,
|
||||||
|
media_type="text/x-opml",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="feeds.opml"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/feeds/import")
|
||||||
|
async def import_feeds(
|
||||||
|
file: UploadFile,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
raw = (await file.read()).decode("utf-8", errors="replace")
|
||||||
|
items = opml.parse_opml(raw)
|
||||||
|
existing = {f.url for f in session.exec(select(Feed)).all()}
|
||||||
|
added = 0
|
||||||
|
for item in items:
|
||||||
|
if item["url"] in existing:
|
||||||
|
continue
|
||||||
|
session.add(Feed(**item))
|
||||||
|
existing.add(item["url"])
|
||||||
|
added += 1
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True, "added": added, "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# API: history & stats
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@app.get("/api/history")
|
||||||
|
def history(
|
||||||
|
limit: int = 100,
|
||||||
|
q: str = "",
|
||||||
|
only_errors: bool = False,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
limit = min(500, max(1, limit))
|
||||||
|
query = select(Notification)
|
||||||
|
if q.strip():
|
||||||
|
like = f"%{q.strip()}%"
|
||||||
|
query = query.where(
|
||||||
|
Notification.title.ilike(like) | Notification.feed_title.ilike(like)
|
||||||
|
)
|
||||||
|
if only_errors:
|
||||||
|
query = query.where(Notification.ok == False) # noqa: E712
|
||||||
|
notes = session.exec(
|
||||||
|
query.order_by(Notification.created_at.desc()).limit(limit)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": n.id,
|
||||||
|
"feed_title": n.feed_title,
|
||||||
|
"title": n.title,
|
||||||
|
"link": n.link,
|
||||||
|
"channels": n.channels,
|
||||||
|
"ok": n.ok,
|
||||||
|
"detail": n.detail,
|
||||||
|
"created_at": n.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for n in notes
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/history")
|
||||||
|
def clear_history(
|
||||||
|
session: Session = Depends(get_session), _: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
for note in session.exec(select(Notification)).all():
|
||||||
|
session.delete(note)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats")
|
||||||
|
def stats(session: Session = Depends(get_session), _: User = Depends(require_auth)):
|
||||||
|
feeds = session.exec(select(Feed)).all()
|
||||||
|
total_sent = session.exec(
|
||||||
|
select(func.count()).select_from(Notification).where(Notification.ok == True) # noqa: E712
|
||||||
|
).one()
|
||||||
|
total_failed = session.exec(
|
||||||
|
select(func.count()).select_from(Notification).where(Notification.ok == False) # noqa: E712
|
||||||
|
).one()
|
||||||
|
return {
|
||||||
|
"feeds_total": len(feeds),
|
||||||
|
"feeds_enabled": sum(1 for f in feeds if f.enabled),
|
||||||
|
"feeds_failing": sum(1 for f in feeds if f.error_streak > 0),
|
||||||
|
"notifications_sent": total_sent,
|
||||||
|
"notifications_failed": total_failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats/activity")
|
||||||
|
def activity(
|
||||||
|
days: int = 14,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Notification counts grouped by day for the last `days` days."""
|
||||||
|
days = min(90, max(1, days))
|
||||||
|
day = func.date(Notification.created_at)
|
||||||
|
rows = session.exec(
|
||||||
|
select(
|
||||||
|
day,
|
||||||
|
func.sum(func.cast(Notification.ok, Integer)),
|
||||||
|
func.count(),
|
||||||
|
).group_by(day)
|
||||||
|
).all()
|
||||||
|
by_day = {str(d): (int(ok or 0), int(total)) for d, ok, total in rows}
|
||||||
|
|
||||||
|
out = []
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
for i in range(days - 1, -1, -1):
|
||||||
|
d = (today - timedelta(days=i)).isoformat()
|
||||||
|
sent, total = by_day.get(d, (0, 0))
|
||||||
|
out.append({"date": d, "sent": sent, "failed": total - sent})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/preview")
|
||||||
|
async def preview(
|
||||||
|
data: PreviewIn,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await fetch_preview(data.url, data.filter_include, data.filter_exclude)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(502, f"Не удалось загрузить ленту: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# API: settings
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@app.get("/api/settings")
|
||||||
|
def read_settings(session: Session = Depends(get_session), _: User = Depends(require_auth)):
|
||||||
|
s = get_settings(session)
|
||||||
|
return {
|
||||||
|
"default_ntfy_server": s.default_ntfy_server,
|
||||||
|
"check_interval": s.check_interval,
|
||||||
|
"auth_enabled": s.auth_enabled,
|
||||||
|
"telegram_enabled": s.telegram_enabled,
|
||||||
|
"telegram_token": s.telegram_token,
|
||||||
|
"telegram_chat_id": s.telegram_chat_id,
|
||||||
|
"webhook_enabled": s.webhook_enabled,
|
||||||
|
"webhook_url": s.webhook_url,
|
||||||
|
"alerts_enabled": s.alerts_enabled,
|
||||||
|
"alert_topic": s.alert_topic,
|
||||||
|
"alert_threshold": s.alert_threshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/settings")
|
||||||
|
def write_settings(
|
||||||
|
data: SettingsIn,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
s = get_settings(session)
|
||||||
|
interval_changed = s.check_interval != data.check_interval
|
||||||
|
|
||||||
|
if data.auth_enabled and not session.exec(select(User)).first():
|
||||||
|
raise HTTPException(400, "Создайте хотя бы одного пользователя перед включением авторизации")
|
||||||
|
|
||||||
|
s.default_ntfy_server = data.default_ntfy_server.strip() or "https://ntfy.sh"
|
||||||
|
s.check_interval = data.check_interval
|
||||||
|
s.auth_enabled = data.auth_enabled
|
||||||
|
s.telegram_enabled = data.telegram_enabled
|
||||||
|
s.telegram_token = data.telegram_token.strip()
|
||||||
|
s.telegram_chat_id = data.telegram_chat_id.strip()
|
||||||
|
s.webhook_enabled = data.webhook_enabled
|
||||||
|
s.webhook_url = data.webhook_url.strip()
|
||||||
|
s.alerts_enabled = data.alerts_enabled
|
||||||
|
s.alert_topic = data.alert_topic.strip()
|
||||||
|
s.alert_threshold = data.alert_threshold
|
||||||
|
|
||||||
|
session.add(s)
|
||||||
|
session.commit()
|
||||||
|
if interval_changed:
|
||||||
|
scheduler.reschedule(data.check_interval)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# API: users
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _user_dict(u: User) -> dict:
|
||||||
|
return {"id": u.id, "username": u.username, "role": u.role}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/users")
|
||||||
|
def list_users(session: Session = Depends(get_session), _: User = Depends(require_admin)):
|
||||||
|
users = session.exec(select(User).order_by(User.id)).all()
|
||||||
|
return [_user_dict(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users")
|
||||||
|
def create_user(
|
||||||
|
data: UserIn,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
if session.exec(select(User).where(User.username == data.username)).first():
|
||||||
|
raise HTTPException(400, "Пользователь с таким логином уже существует")
|
||||||
|
if not data.password.strip():
|
||||||
|
raise HTTPException(400, "Пароль обязателен для нового пользователя")
|
||||||
|
user = User(
|
||||||
|
username=data.username,
|
||||||
|
password_hash=hash_password(data.password),
|
||||||
|
role=data.role,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return _user_dict(user)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/users/{user_id}")
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
data: UserIn,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(404, "Пользователь не найден")
|
||||||
|
# Don't allow demoting the last remaining admin.
|
||||||
|
if user.role == "admin" and data.role != "admin":
|
||||||
|
admins = session.exec(select(User).where(User.role == "admin")).all()
|
||||||
|
if len(admins) <= 1:
|
||||||
|
raise HTTPException(400, "Нельзя понизить последнего администратора")
|
||||||
|
user.username = data.username
|
||||||
|
user.role = data.role
|
||||||
|
if data.password.strip():
|
||||||
|
user.password_hash = hash_password(data.password)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
return _user_dict(user)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/users/{user_id}")
|
||||||
|
def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
me: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(404, "Пользователь не найден")
|
||||||
|
if user.id == me.id:
|
||||||
|
raise HTTPException(400, "Нельзя удалить самого себя")
|
||||||
|
if user.role == "admin":
|
||||||
|
admins = session.exec(select(User).where(User.role == "admin")).all()
|
||||||
|
if len(admins) <= 1:
|
||||||
|
raise HTTPException(400, "Нельзя удалить последнего администратора")
|
||||||
|
session.delete(user)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# API: test notification
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@app.post("/api/test")
|
||||||
|
async def test_notification(
|
||||||
|
data: TestIn,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: User = Depends(require_auth),
|
||||||
|
):
|
||||||
|
s = get_settings(session)
|
||||||
|
server = data.server.strip() or s.default_ntfy_server
|
||||||
|
if not data.topic.strip():
|
||||||
|
raise HTTPException(400, "Укажите тему")
|
||||||
|
try:
|
||||||
|
await ntfy.publish(
|
||||||
|
server=server,
|
||||||
|
topic=data.topic,
|
||||||
|
title="RSS to ntfy",
|
||||||
|
message="Тестовое уведомление — всё работает!",
|
||||||
|
tags="white_check_mark",
|
||||||
|
priority=3,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(502, f"Не удалось отправить: {exc}")
|
||||||
|
return {"ok": True, "sent_to": f"{server.rstrip('/')}/{data.topic}"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exc_handler(request: Request, exc: HTTPException):
|
||||||
|
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""Database models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Feed(SQLModel, table=True):
|
||||||
|
"""A single RSS/Atom feed to monitor."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
title: str = ""
|
||||||
|
url: str = Field(index=True)
|
||||||
|
|
||||||
|
# --- ntfy target (per-feed; empty server falls back to global default) ---
|
||||||
|
ntfy_server: str = ""
|
||||||
|
ntfy_topic: str = ""
|
||||||
|
# Optional access token / Basic-auth for private ntfy servers.
|
||||||
|
ntfy_token: str = "" # bearer token (tk_...)
|
||||||
|
ntfy_username: str = "" # OR basic auth user
|
||||||
|
ntfy_password: str = "" # OR basic auth password
|
||||||
|
priority: int = 3 # 1=min .. 5=max
|
||||||
|
tags: str = "" # comma separated ntfy tags/emojis
|
||||||
|
attach_image: bool = True # attach first image found in the entry
|
||||||
|
|
||||||
|
# --- alternative delivery channels (per-feed opt-in) ---
|
||||||
|
to_telegram: bool = False
|
||||||
|
to_webhook: bool = False
|
||||||
|
|
||||||
|
# --- keyword filters ---
|
||||||
|
# Only entries containing at least one include keyword (if any) AND
|
||||||
|
# none of the exclude keywords are forwarded. Comma separated, case-insensitive.
|
||||||
|
filter_include: str = ""
|
||||||
|
filter_exclude: str = ""
|
||||||
|
|
||||||
|
# --- scheduling ---
|
||||||
|
# Per-feed interval in minutes. 0 = use the global default.
|
||||||
|
interval: int = 0
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
# --- state ---
|
||||||
|
last_checked: Optional[datetime] = None
|
||||||
|
last_status: str = "" # human readable result of last check
|
||||||
|
error_streak: int = 0 # consecutive failures (for admin alerts)
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SeenEntry(SQLModel, table=True):
|
||||||
|
"""Tracks which feed entries have already been pushed to avoid duplicates."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
feed_id: int = Field(index=True, foreign_key="feed.id")
|
||||||
|
entry_uid: str = Field(index=True)
|
||||||
|
seen_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(SQLModel, table=True):
|
||||||
|
"""History of dispatched (or failed) notifications."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
feed_id: int = Field(index=True, foreign_key="feed.id")
|
||||||
|
feed_title: str = ""
|
||||||
|
title: str = ""
|
||||||
|
link: str = ""
|
||||||
|
channels: str = "" # e.g. "ntfy,telegram"
|
||||||
|
ok: bool = True
|
||||||
|
detail: str = "" # error text when ok is False
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class User(SQLModel, table=True):
|
||||||
|
"""A web-panel user. Roles: 'admin' (full) or 'viewer' (read-only)."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
username: str = Field(index=True)
|
||||||
|
password_hash: str = ""
|
||||||
|
role: str = "admin" # admin | viewer
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(SQLModel, table=True):
|
||||||
|
"""Singleton settings row (id == 1)."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=1, primary_key=True)
|
||||||
|
default_ntfy_server: str = "https://ntfy.sh"
|
||||||
|
check_interval: int = 5 # minutes (global default)
|
||||||
|
|
||||||
|
# Auth toggle (per-user credentials live in the User table).
|
||||||
|
auth_enabled: bool = False
|
||||||
|
|
||||||
|
# --- Telegram channel ---
|
||||||
|
telegram_enabled: bool = False
|
||||||
|
telegram_token: str = ""
|
||||||
|
telegram_chat_id: str = ""
|
||||||
|
|
||||||
|
# --- Generic webhook channel ---
|
||||||
|
webhook_enabled: bool = False
|
||||||
|
webhook_url: str = ""
|
||||||
|
|
||||||
|
# --- Admin health alerts ---
|
||||||
|
alerts_enabled: bool = False
|
||||||
|
alert_topic: str = "" # ntfy topic to notify when a feed keeps failing
|
||||||
|
alert_threshold: int = 3 # consecutive failures before alerting
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
"""Publishing notifications to an ntfy server."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def _topic_url(server: str, topic: str) -> str:
|
||||||
|
server = (server or "https://ntfy.sh").rstrip("/")
|
||||||
|
return f"{server}/{quote(topic.strip('/'))}"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(token: str, username: str, password: str) -> dict[str, str]:
|
||||||
|
"""Build an Authorization header for private ntfy servers."""
|
||||||
|
if token.strip():
|
||||||
|
return {"Authorization": f"Bearer {token.strip()}"}
|
||||||
|
if username.strip():
|
||||||
|
import base64
|
||||||
|
|
||||||
|
raw = f"{username}:{password}".encode()
|
||||||
|
return {"Authorization": "Basic " + base64.b64encode(raw).decode()}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def publish(
|
||||||
|
*,
|
||||||
|
server: str,
|
||||||
|
topic: str,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
click: str = "",
|
||||||
|
tags: str = "",
|
||||||
|
priority: int = 3,
|
||||||
|
attach: str = "",
|
||||||
|
token: str = "",
|
||||||
|
username: str = "",
|
||||||
|
password: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Send one notification to ntfy. Raises httpx.HTTPStatusError on failure.
|
||||||
|
|
||||||
|
Title and click URLs must be ASCII (ntfy header limitation), so non-ASCII
|
||||||
|
titles are pushed into the body and the title is best-effort stripped.
|
||||||
|
"""
|
||||||
|
url = _topic_url(server, topic)
|
||||||
|
headers: dict[str, str] = {"Priority": str(priority)}
|
||||||
|
headers.update(_auth_headers(token, username, password))
|
||||||
|
|
||||||
|
ascii_title = title.encode("ascii", "ignore").decode().strip()
|
||||||
|
if ascii_title:
|
||||||
|
headers["Title"] = ascii_title
|
||||||
|
elif title.strip():
|
||||||
|
# Title had only non-ASCII chars — prepend it to the body instead.
|
||||||
|
message = f"{title}\n\n{message}"
|
||||||
|
|
||||||
|
if click:
|
||||||
|
try:
|
||||||
|
click.encode("ascii")
|
||||||
|
headers["Click"] = click
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
pass
|
||||||
|
if tags.strip():
|
||||||
|
clean = ",".join(t.strip() for t in tags.split(",") if t.strip())
|
||||||
|
if clean:
|
||||||
|
headers["Tags"] = clean
|
||||||
|
if attach:
|
||||||
|
try:
|
||||||
|
attach.encode("ascii")
|
||||||
|
headers["Attach"] = attach
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
resp = await client.post(url, content=message.encode("utf-8"), headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
"""OPML import/export for feed subscriptions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from .models import Feed
|
||||||
|
|
||||||
|
|
||||||
|
def export_opml(feeds: list[Feed]) -> str:
|
||||||
|
"""Render feeds as an OPML 2.0 document."""
|
||||||
|
opml = ET.Element("opml", version="2.0")
|
||||||
|
head = ET.SubElement(opml, "head")
|
||||||
|
ET.SubElement(head, "title").text = "RSS → ntfy subscriptions"
|
||||||
|
body = ET.SubElement(opml, "body")
|
||||||
|
for feed in feeds:
|
||||||
|
attrs = {
|
||||||
|
"type": "rss",
|
||||||
|
"text": feed.title or feed.url,
|
||||||
|
"title": feed.title or feed.url,
|
||||||
|
"xmlUrl": feed.url,
|
||||||
|
}
|
||||||
|
# Stash the ntfy topic so a re-import keeps the routing.
|
||||||
|
if feed.ntfy_topic:
|
||||||
|
attrs["ntfyTopic"] = feed.ntfy_topic
|
||||||
|
if feed.ntfy_server:
|
||||||
|
attrs["ntfyServer"] = feed.ntfy_server
|
||||||
|
ET.SubElement(body, "outline", attrs)
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
|
||||||
|
opml, encoding="unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_opml(content: str) -> list[dict]:
|
||||||
|
"""Extract feed definitions from an OPML document.
|
||||||
|
|
||||||
|
Returns a list of dicts ready to build Feed rows. Raises ValueError on
|
||||||
|
malformed XML.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
raise ValueError(f"Некорректный OPML: {exc}") from exc
|
||||||
|
|
||||||
|
feeds: list[dict] = []
|
||||||
|
for outline in root.iter("outline"):
|
||||||
|
url = outline.get("xmlUrl") or outline.get("xmlurl")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
feeds.append(
|
||||||
|
{
|
||||||
|
"url": url.strip(),
|
||||||
|
"title": (outline.get("title") or outline.get("text") or "").strip(),
|
||||||
|
"ntfy_topic": (outline.get("ntfyTopic") or "").strip(),
|
||||||
|
"ntfy_server": (outline.get("ntfyServer") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return feeds
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""APScheduler wrapper that ticks every minute and lets the checker decide
|
||||||
|
which feeds are due (per-feed intervals are evaluated in check_all_feeds)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
|
from .checker import check_all_feeds
|
||||||
|
|
||||||
|
log = logging.getLogger("scheduler")
|
||||||
|
|
||||||
|
_scheduler: AsyncIOScheduler | None = None
|
||||||
|
_JOB_ID = "check-feeds"
|
||||||
|
# Fixed tick; per-feed/global intervals are honoured inside check_all_feeds.
|
||||||
|
_TICK_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def start(interval_minutes: int) -> None:
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
return
|
||||||
|
_scheduler = AsyncIOScheduler(timezone="UTC")
|
||||||
|
_scheduler.add_job(
|
||||||
|
check_all_feeds,
|
||||||
|
trigger=IntervalTrigger(seconds=_TICK_SECONDS),
|
||||||
|
id=_JOB_ID,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
_scheduler.start()
|
||||||
|
log.info("Планировщик запущен (тик 60с), интервал по умолчанию %d мин", interval_minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def reschedule(interval_minutes: int) -> None:
|
||||||
|
# The global interval is read live by the checker each tick, so there is
|
||||||
|
# nothing to reschedule — kept for API compatibility.
|
||||||
|
log.info("Интервал по умолчанию изменён на %d мин", interval_minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown() -> None:
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
_scheduler = None
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""Pydantic request/response schemas for the JSON API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class FeedIn(BaseModel):
|
||||||
|
url: str
|
||||||
|
title: str = ""
|
||||||
|
ntfy_server: str = ""
|
||||||
|
ntfy_topic: str = ""
|
||||||
|
ntfy_token: str = ""
|
||||||
|
ntfy_username: str = ""
|
||||||
|
ntfy_password: str = ""
|
||||||
|
priority: int = 3
|
||||||
|
tags: str = ""
|
||||||
|
attach_image: bool = True
|
||||||
|
to_telegram: bool = False
|
||||||
|
to_webhook: bool = False
|
||||||
|
filter_include: str = ""
|
||||||
|
filter_exclude: str = ""
|
||||||
|
interval: int = 0
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
@field_validator("url")
|
||||||
|
@classmethod
|
||||||
|
def _url_required(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("URL ленты обязателен")
|
||||||
|
if not v.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("URL должен начинаться с http:// или https://")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("priority")
|
||||||
|
@classmethod
|
||||||
|
def _priority_range(cls, v: int) -> int:
|
||||||
|
return min(5, max(1, v))
|
||||||
|
|
||||||
|
@field_validator("interval")
|
||||||
|
@classmethod
|
||||||
|
def _interval_nonneg(cls, v: int) -> int:
|
||||||
|
return max(0, v)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsIn(BaseModel):
|
||||||
|
default_ntfy_server: str = "https://ntfy.sh"
|
||||||
|
check_interval: int = 5
|
||||||
|
auth_enabled: bool = False
|
||||||
|
# Telegram
|
||||||
|
telegram_enabled: bool = False
|
||||||
|
telegram_token: str = ""
|
||||||
|
telegram_chat_id: str = ""
|
||||||
|
# Webhook
|
||||||
|
webhook_enabled: bool = False
|
||||||
|
webhook_url: str = ""
|
||||||
|
# Admin alerts
|
||||||
|
alerts_enabled: bool = False
|
||||||
|
alert_topic: str = ""
|
||||||
|
alert_threshold: int = 3
|
||||||
|
|
||||||
|
@field_validator("check_interval")
|
||||||
|
@classmethod
|
||||||
|
def _interval_min(cls, v: int) -> int:
|
||||||
|
return max(1, v)
|
||||||
|
|
||||||
|
@field_validator("alert_threshold")
|
||||||
|
@classmethod
|
||||||
|
def _threshold_min(cls, v: int) -> int:
|
||||||
|
return max(1, v)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIn(BaseModel):
|
||||||
|
server: str = ""
|
||||||
|
topic: str
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewIn(BaseModel):
|
||||||
|
url: str
|
||||||
|
filter_include: str = ""
|
||||||
|
filter_exclude: str = ""
|
||||||
|
|
||||||
|
@field_validator("url")
|
||||||
|
@classmethod
|
||||||
|
def _url_required(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("URL должен начинаться с http:// или https://")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UserIn(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str = "" # empty on edit = keep existing
|
||||||
|
role: str = "admin"
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def _username_required(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Логин обязателен")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("role")
|
||||||
|
@classmethod
|
||||||
|
def _role_valid(cls, v: str) -> str:
|
||||||
|
return v if v in ("admin", "viewer") else "viewer"
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
|
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||||
|
|
||||||
|
let ME = { role: "admin", auth_enabled: false };
|
||||||
|
|
||||||
|
// ---------- API helper ----------
|
||||||
|
async function api(method, url, body) {
|
||||||
|
const opts = { method, headers: {} };
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers["Content-Type"] = "application/json";
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
if (res.status === 401) { location.href = "/login"; throw new Error("auth"); }
|
||||||
|
const data = res.headers.get("content-type")?.includes("json")
|
||||||
|
? await res.json() : null;
|
||||||
|
if (!res.ok) throw new Error(data?.detail || `Error ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Toast ----------
|
||||||
|
let toastTimer;
|
||||||
|
function toast(msg, kind = "ok") {
|
||||||
|
const el = $("#toast");
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = `toast show ${kind}`;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => { el.className = "toast hidden"; }, 3400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- utils ----------
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str ?? "").replace(/[&<>"']/g, c =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return t("feeds.never");
|
||||||
|
return new Date(iso).toLocaleString(localeTag(),
|
||||||
|
{ day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localize a status code emitted by the backend (e.g. "sent:3:1").
|
||||||
|
function formatStatus(code) {
|
||||||
|
if (!code) return t("status.dash");
|
||||||
|
const i = code.indexOf(":");
|
||||||
|
const head = i === -1 ? code : code.slice(0, i);
|
||||||
|
const rest = i === -1 ? "" : code.slice(i + 1);
|
||||||
|
switch (head) {
|
||||||
|
case "init": return t("status.init", { n: rest });
|
||||||
|
case "sent": {
|
||||||
|
const [n, s] = rest.split(":");
|
||||||
|
return s ? t("status.sentSkip", { n, s }) : t("status.sent", { n });
|
||||||
|
}
|
||||||
|
case "filtered": return t("status.filtered", { s: rest });
|
||||||
|
case "nochange": return t("status.nochange");
|
||||||
|
case "parse_error": return t("status.parseError", { msg: rest });
|
||||||
|
case "send_error": return t("status.sendError", { msg: rest });
|
||||||
|
default: return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function isErrorStatus(code) {
|
||||||
|
return /^(parse_error|send_error)/.test(code || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Stats + chart ----------
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const s = await api("GET", "/api/stats");
|
||||||
|
$("#stats").innerHTML = `
|
||||||
|
<div class="stat"><b>${s.feeds_total}</b><span>${t("stats.feeds")}</span></div>
|
||||||
|
<div class="stat"><b>${s.feeds_enabled}</b><span>${t("stats.enabled")}</span></div>
|
||||||
|
<div class="stat ${s.feeds_failing ? "warn" : ""}"><b>${s.feeds_failing}</b><span>${t("stats.failing")}</span></div>
|
||||||
|
<div class="stat"><b>${s.notifications_sent}</b><span>${t("stats.sent")}</span></div>
|
||||||
|
<div class="stat ${s.notifications_failed ? "warn" : ""}"><b>${s.notifications_failed}</b><span>${t("stats.failed")}</span></div>`;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActivity() {
|
||||||
|
let data;
|
||||||
|
try { data = await api("GET", "/api/stats/activity?days=14"); } catch { return; }
|
||||||
|
const total = data.reduce((a, d) => a + d.sent + d.failed, 0);
|
||||||
|
const wrap = $("#chart-wrap");
|
||||||
|
if (!total) { wrap.classList.add("hidden"); return; }
|
||||||
|
wrap.classList.remove("hidden");
|
||||||
|
|
||||||
|
const max = Math.max(1, ...data.map(d => d.sent + d.failed));
|
||||||
|
const W = 100, H = 38, n = data.length, gap = 1.2;
|
||||||
|
const bw = (W - gap * (n - 1)) / n;
|
||||||
|
let bars = "";
|
||||||
|
data.forEach((d, i) => {
|
||||||
|
const x = i * (bw + gap);
|
||||||
|
const sentH = (d.sent / max) * H;
|
||||||
|
const failH = (d.failed / max) * H;
|
||||||
|
const day = new Date(d.date + "T00:00").toLocaleDateString(localeTag(), { day: "2-digit", month: "short" });
|
||||||
|
const title = `${day}: ${t("chart.sent")} ${d.sent}, ${t("chart.failed")} ${d.failed}`;
|
||||||
|
bars += `<g><title>${escapeHtml(title)}</title>`;
|
||||||
|
bars += `<rect class="bar-sent" x="${x.toFixed(2)}" y="${(H - sentH).toFixed(2)}" width="${bw.toFixed(2)}" height="${sentH.toFixed(2)}" rx="0.4"/>`;
|
||||||
|
if (failH > 0)
|
||||||
|
bars += `<rect class="bar-fail" x="${x.toFixed(2)}" y="${(H - sentH - failH).toFixed(2)}" width="${bw.toFixed(2)}" height="${failH.toFixed(2)}" rx="0.4"/>`;
|
||||||
|
bars += `</g>`;
|
||||||
|
});
|
||||||
|
$("#chart").innerHTML =
|
||||||
|
`<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="chart-svg">${bars}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Feeds ----------
|
||||||
|
function feedCard(f) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "feed-card" + (f.enabled ? "" : " disabled");
|
||||||
|
const chips = [];
|
||||||
|
chips.push(`<span class="chip topic">📨 ${escapeHtml(f.ntfy_topic || t("feeds.noTopic"))}</span>`);
|
||||||
|
if (f.ntfy_server) chips.push(`<span class="chip">🖥️ ${escapeHtml(f.ntfy_server)}</span>`);
|
||||||
|
if (f.ntfy_token || f.ntfy_username) chips.push(`<span class="chip">🔐 auth</span>`);
|
||||||
|
chips.push(`<span class="chip">⚡ P${f.priority}</span>`);
|
||||||
|
if (f.interval) chips.push(`<span class="chip">⏱ ${f.interval}m</span>`);
|
||||||
|
if (f.to_telegram) chips.push(`<span class="chip tg">✈️ TG</span>`);
|
||||||
|
if (f.to_webhook) chips.push(`<span class="chip">🔗 hook</span>`);
|
||||||
|
if (f.filter_include || f.filter_exclude) chips.push(`<span class="chip">🧩</span>`);
|
||||||
|
if (f.tags) chips.push(`<span class="chip">🏷️ ${escapeHtml(f.tags)}</span>`);
|
||||||
|
|
||||||
|
const admin = ME.role === "admin";
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="feed-top">
|
||||||
|
<span class="dot ${f.enabled ? "on" : "off"}"></span>
|
||||||
|
<div style="min-width:0;flex:1">
|
||||||
|
<div class="feed-title">${escapeHtml(f.title || f.url)}</div>
|
||||||
|
<div class="feed-url">${escapeHtml(f.url)}</div>
|
||||||
|
<div class="feed-meta">${chips.join("")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="feed-actions">
|
||||||
|
<button class="btn ghost small" data-act="check">↻</button>
|
||||||
|
${admin ? `<button class="btn ghost small" data-act="edit">✎</button>
|
||||||
|
<button class="btn danger small" data-act="del">🗑</button>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feed-status">
|
||||||
|
<span class="${isErrorStatus(f.last_status) ? "err" : (f.last_status.startsWith("sent") ? "ok" : "")}">${escapeHtml(formatStatus(f.last_status))}</span>
|
||||||
|
· ${fmtDate(f.last_checked)}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
$('[data-act="check"]', el).onclick = (e) => checkFeed(f, e.currentTarget);
|
||||||
|
if (admin) {
|
||||||
|
$('[data-act="edit"]', el).onclick = () => openModal(f);
|
||||||
|
$('[data-act="del"]', el).onclick = () => deleteFeed(f);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFeeds() {
|
||||||
|
const feeds = await api("GET", "/api/feeds");
|
||||||
|
const list = $("#feeds-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
$("#feeds-empty").classList.toggle("hidden", feeds.length > 0);
|
||||||
|
feeds.forEach(f => list.appendChild(feedCard(f)));
|
||||||
|
loadStats();
|
||||||
|
loadActivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFeed(f) {
|
||||||
|
if (!confirm(t("confirm.deleteFeed", { name: f.title || f.url }))) return;
|
||||||
|
await api("DELETE", `/api/feeds/${f.id}`);
|
||||||
|
toast(t("toast.feedDeleted"));
|
||||||
|
loadFeeds();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkFeed(f, btn) {
|
||||||
|
const old = btn.textContent;
|
||||||
|
btn.textContent = "…"; btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api("POST", `/api/feeds/${f.id}/check`);
|
||||||
|
toast(formatStatus(r.status), isErrorStatus(r.status) ? "err" : "ok");
|
||||||
|
loadFeeds();
|
||||||
|
} catch (e) { toast(e.message, "err"); }
|
||||||
|
finally { btn.textContent = old; btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Feed modal ----------
|
||||||
|
const modal = $("#modal");
|
||||||
|
const feedForm = $("#feed-form");
|
||||||
|
|
||||||
|
function openModal(feed) {
|
||||||
|
feedForm.reset();
|
||||||
|
$("#preview-area").innerHTML = "";
|
||||||
|
$("#modal-title").textContent = feed ? t("modal.editFeed") : t("modal.addFeed");
|
||||||
|
feedForm.id.value = feed?.id || "";
|
||||||
|
const f = feed || { attach_image: true, enabled: true, priority: 3, interval: 0 };
|
||||||
|
for (const el of feedForm.elements) {
|
||||||
|
if (!el.name || el.name === "id") continue;
|
||||||
|
if (el.type === "checkbox") el.checked = !!f[el.name];
|
||||||
|
else if (f[el.name] !== undefined) el.value = f[el.name];
|
||||||
|
}
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
function closeModal() { modal.classList.add("hidden"); }
|
||||||
|
|
||||||
|
$("#add-feed").onclick = () => openModal(null);
|
||||||
|
$("#modal-close").onclick = closeModal;
|
||||||
|
$("#modal-cancel").onclick = closeModal;
|
||||||
|
modal.addEventListener("click", e => { if (e.target === modal) closeModal(); });
|
||||||
|
|
||||||
|
$("#preview-btn").onclick = async () => {
|
||||||
|
const url = feedForm.url.value.trim();
|
||||||
|
if (!url) { toast(t("toast.needUrl"), "err"); return; }
|
||||||
|
const area = $("#preview-area");
|
||||||
|
area.innerHTML = `<div class="muted">${t("feed.previewLoading")}</div>`;
|
||||||
|
try {
|
||||||
|
const p = await api("POST", "/api/preview", {
|
||||||
|
url,
|
||||||
|
filter_include: feedForm.filter_include.value.trim(),
|
||||||
|
filter_exclude: feedForm.filter_exclude.value.trim(),
|
||||||
|
});
|
||||||
|
const img = p.image ? `<img src="${escapeHtml(p.image)}" alt="" loading="lazy">` : "";
|
||||||
|
area.innerHTML = `
|
||||||
|
<div class="ntfy-preview">
|
||||||
|
<div class="np-head">📡 ${escapeHtml(p.source || feedForm.title.value || "")}</div>
|
||||||
|
<div class="np-title">${escapeHtml(p.title)}</div>
|
||||||
|
<div class="np-body">${escapeHtml(p.body || "")}</div>
|
||||||
|
${img}
|
||||||
|
</div>`;
|
||||||
|
} catch (err) {
|
||||||
|
area.innerHTML = `<div class="alert error">${escapeHtml(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
feedForm.addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
url: feedForm.url.value.trim(),
|
||||||
|
title: feedForm.title.value.trim(),
|
||||||
|
ntfy_server: feedForm.ntfy_server.value.trim(),
|
||||||
|
ntfy_topic: feedForm.ntfy_topic.value.trim(),
|
||||||
|
ntfy_token: feedForm.ntfy_token.value.trim(),
|
||||||
|
ntfy_username: feedForm.ntfy_username.value.trim(),
|
||||||
|
ntfy_password: feedForm.ntfy_password.value,
|
||||||
|
priority: parseInt(feedForm.priority.value, 10),
|
||||||
|
interval: parseInt(feedForm.interval.value, 10) || 0,
|
||||||
|
tags: feedForm.tags.value.trim(),
|
||||||
|
filter_include: feedForm.filter_include.value.trim(),
|
||||||
|
filter_exclude: feedForm.filter_exclude.value.trim(),
|
||||||
|
attach_image: feedForm.attach_image.checked,
|
||||||
|
to_telegram: feedForm.to_telegram.checked,
|
||||||
|
to_webhook: feedForm.to_webhook.checked,
|
||||||
|
enabled: feedForm.enabled.checked,
|
||||||
|
};
|
||||||
|
const id = feedForm.id.value;
|
||||||
|
try {
|
||||||
|
if (id) await api("PUT", `/api/feeds/${id}`, payload);
|
||||||
|
else await api("POST", "/api/feeds", payload);
|
||||||
|
toast(id ? t("toast.feedUpdated") : t("toast.feedAdded"));
|
||||||
|
closeModal();
|
||||||
|
loadFeeds();
|
||||||
|
} catch (err) { toast(err.message, "err"); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#check-all").onclick = async (e) => {
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
btn.disabled = true;
|
||||||
|
const feeds = await api("GET", "/api/feeds");
|
||||||
|
for (const f of feeds.filter(x => x.enabled)) {
|
||||||
|
try { await api("POST", `/api/feeds/${f.id}/check`); } catch (_) {}
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
toast(t("toast.checkDone"));
|
||||||
|
loadFeeds();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- OPML ----------
|
||||||
|
$("#export-btn").onclick = () => { location.href = "/api/feeds/export"; };
|
||||||
|
$("#import-btn").onclick = () => $("#opml-file").click();
|
||||||
|
$("#opml-file").onchange = async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/feeds/import", { method: "POST", body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || "Error");
|
||||||
|
toast(t("toast.imported", { added: data.added, total: data.total }));
|
||||||
|
loadFeeds();
|
||||||
|
} catch (err) { toast(err.message, "err"); }
|
||||||
|
finally { e.target.value = ""; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- History ----------
|
||||||
|
let historyTimer;
|
||||||
|
async function loadHistory() {
|
||||||
|
const q = encodeURIComponent($("#history-search").value.trim());
|
||||||
|
const onlyErr = $("#history-errors").checked;
|
||||||
|
const notes = await api("GET", `/api/history?limit=200&q=${q}&only_errors=${onlyErr}`);
|
||||||
|
const list = $("#history-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
$("#history-empty").classList.toggle("hidden", notes.length > 0);
|
||||||
|
notes.forEach(n => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "history-row " + (n.ok ? "ok" : "err");
|
||||||
|
const channels = n.channels
|
||||||
|
? n.channels.split(",").map(c => `<span class="chip">${escapeHtml(c)}</span>`).join("")
|
||||||
|
: "";
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="history-icon">${n.ok ? "✅" : "⚠️"}</div>
|
||||||
|
<div class="history-main">
|
||||||
|
<div class="history-title">${n.link
|
||||||
|
? `<a href="${escapeHtml(n.link)}" target="_blank" rel="noopener">${escapeHtml(n.title)}</a>`
|
||||||
|
: escapeHtml(n.title)}</div>
|
||||||
|
<div class="history-sub">
|
||||||
|
<span class="muted">${escapeHtml(n.feed_title || "")}</span> ${channels}
|
||||||
|
${n.detail ? `<span class="err">${escapeHtml(n.detail)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-time muted">${fmtDate(n.created_at)}</div>`;
|
||||||
|
list.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function debouncedHistory() {
|
||||||
|
clearTimeout(historyTimer);
|
||||||
|
historyTimer = setTimeout(() => loadHistory().catch(e => toast(e.message, "err")), 250);
|
||||||
|
}
|
||||||
|
$("#history-search").oninput = debouncedHistory;
|
||||||
|
$("#history-errors").onchange = debouncedHistory;
|
||||||
|
$("#history-refresh").onclick = () => loadHistory().catch(e => toast(e.message, "err"));
|
||||||
|
$("#history-clear").onclick = async () => {
|
||||||
|
if (!confirm(t("confirm.clearHistory"))) return;
|
||||||
|
await api("DELETE", "/api/history");
|
||||||
|
toast(t("toast.historyCleared"));
|
||||||
|
loadHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Users ----------
|
||||||
|
const userModal = $("#user-modal");
|
||||||
|
const userForm = $("#user-form");
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const users = await api("GET", "/api/users");
|
||||||
|
const list = $("#users-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
users.forEach(u => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "feed-card";
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="feed-top">
|
||||||
|
<span class="dot on"></span>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="feed-title">${escapeHtml(u.username)}</div>
|
||||||
|
<div class="feed-meta"><span class="chip ${u.role === "admin" ? "topic" : ""}">
|
||||||
|
${u.role === "admin" ? t("users.admin") : t("users.viewer")}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="feed-actions">
|
||||||
|
<button class="btn ghost small" data-act="edit">✎</button>
|
||||||
|
<button class="btn danger small" data-act="del">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
$('[data-act="edit"]', el).onclick = () => openUserModal(u);
|
||||||
|
$('[data-act="del"]', el).onclick = async () => {
|
||||||
|
if (!confirm(t("confirm.deleteUser", { name: u.username }))) return;
|
||||||
|
try { await api("DELETE", `/api/users/${u.id}`); toast(t("toast.deleted")); loadUsers(); }
|
||||||
|
catch (e) { toast(e.message, "err"); }
|
||||||
|
};
|
||||||
|
list.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserModal(user) {
|
||||||
|
userForm.reset();
|
||||||
|
$("#user-modal-title").textContent = user ? t("user.editTitle") : t("user.addTitle");
|
||||||
|
$("#pw-hint").textContent = user ? t("user.pwKeep") : t("user.pwReq");
|
||||||
|
userForm.id.value = user?.id || "";
|
||||||
|
userForm.username.value = user?.username || "";
|
||||||
|
userForm.role.value = user?.role || "admin";
|
||||||
|
userModal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
function closeUserModal() { userModal.classList.add("hidden"); }
|
||||||
|
$("#add-user").onclick = () => openUserModal(null);
|
||||||
|
$("#user-modal-close").onclick = closeUserModal;
|
||||||
|
$("#user-modal-cancel").onclick = closeUserModal;
|
||||||
|
userModal.addEventListener("click", e => { if (e.target === userModal) closeUserModal(); });
|
||||||
|
|
||||||
|
userForm.addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
username: userForm.username.value.trim(),
|
||||||
|
password: userForm.password.value,
|
||||||
|
role: userForm.role.value,
|
||||||
|
};
|
||||||
|
const id = userForm.id.value;
|
||||||
|
try {
|
||||||
|
if (id) await api("PUT", `/api/users/${id}`, payload);
|
||||||
|
else await api("POST", "/api/users", payload);
|
||||||
|
toast(t("toast.saved"));
|
||||||
|
closeUserModal();
|
||||||
|
loadUsers();
|
||||||
|
} catch (err) { toast(err.message, "err"); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Settings ----------
|
||||||
|
const sForm = $("#settings-form");
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const s = await api("GET", "/api/settings");
|
||||||
|
for (const el of sForm.elements) {
|
||||||
|
if (!el.name) continue;
|
||||||
|
if (el.type === "checkbox") el.checked = !!s[el.name];
|
||||||
|
else if (s[el.name] !== undefined) el.value = s[el.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sForm.addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
default_ntfy_server: sForm.default_ntfy_server.value.trim(),
|
||||||
|
check_interval: parseInt(sForm.check_interval.value, 10),
|
||||||
|
auth_enabled: sForm.auth_enabled.checked,
|
||||||
|
telegram_enabled: sForm.telegram_enabled.checked,
|
||||||
|
telegram_token: sForm.telegram_token.value.trim(),
|
||||||
|
telegram_chat_id: sForm.telegram_chat_id.value.trim(),
|
||||||
|
webhook_enabled: sForm.webhook_enabled.checked,
|
||||||
|
webhook_url: sForm.webhook_url.value.trim(),
|
||||||
|
alerts_enabled: sForm.alerts_enabled.checked,
|
||||||
|
alert_topic: sForm.alert_topic.value.trim(),
|
||||||
|
alert_threshold: parseInt(sForm.alert_threshold.value, 10) || 3,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/settings", payload);
|
||||||
|
toast(t("toast.settingsSaved"));
|
||||||
|
} catch (err) { toast(err.message, "err"); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#test-btn").onclick = async () => {
|
||||||
|
const topic = $("#test-topic").value.trim();
|
||||||
|
if (!topic) { toast(t("toast.needTestTopic"), "err"); return; }
|
||||||
|
try {
|
||||||
|
const r = await api("POST", "/api/test", {
|
||||||
|
server: sForm.default_ntfy_server.value.trim(), topic,
|
||||||
|
});
|
||||||
|
toast(t("toast.sentTo", { dest: r.sent_to }));
|
||||||
|
} catch (err) { toast(err.message, "err"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Tabs ----------
|
||||||
|
$$(".tab").forEach(tab => tab.addEventListener("click", () => {
|
||||||
|
$$(".tab").forEach(t => t.classList.remove("active"));
|
||||||
|
$$(".tab-panel").forEach(p => p.classList.remove("active"));
|
||||||
|
tab.classList.add("active");
|
||||||
|
$(`#tab-${tab.dataset.tab}`).classList.add("active");
|
||||||
|
if (tab.dataset.tab === "history") loadHistory().catch(() => {});
|
||||||
|
if (tab.dataset.tab === "users") loadUsers().catch(() => {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------- Theme + language ----------
|
||||||
|
$("#theme-btn").onclick = () => setTheme(getTheme() === "dark" ? "light" : "dark");
|
||||||
|
|
||||||
|
const langSelect = $("#lang-select");
|
||||||
|
langSelect.value = getLang();
|
||||||
|
langSelect.onchange = () => {
|
||||||
|
setLang(langSelect.value);
|
||||||
|
applyI18n();
|
||||||
|
renderWhoami();
|
||||||
|
// Re-render dynamic content in the new language.
|
||||||
|
loadFeeds().catch(() => {});
|
||||||
|
if ($("#tab-history").classList.contains("active")) loadHistory().catch(() => {});
|
||||||
|
if ($("#tab-users").classList.contains("active")) loadUsers().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderWhoami() {
|
||||||
|
if (ME.auth_enabled) {
|
||||||
|
$("#whoami").textContent = `${ME.username} · ${ME.role === "admin" ? t("role.admin") : t("role.viewer")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- init ----------
|
||||||
|
async function init() {
|
||||||
|
applyI18n();
|
||||||
|
try { ME = await api("GET", "/api/me"); } catch (_) {}
|
||||||
|
if (ME.role !== "admin") $$(".admin-only").forEach(el => el.classList.add("hidden"));
|
||||||
|
if (ME.auth_enabled) { $("#logout-btn").style.display = ""; renderWhoami(); }
|
||||||
|
loadFeeds().catch(e => toast(e.message, "err"));
|
||||||
|
if (ME.role === "admin") loadSettings().catch(e => toast(e.message, "err"));
|
||||||
|
}
|
||||||
|
init();
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"use strict";
|
||||||
|
/* Lightweight i18n: dictionaries + t() + applyI18n(). Shared by login & app. */
|
||||||
|
|
||||||
|
const I18N = {
|
||||||
|
ru: {
|
||||||
|
"nav.feeds": "Ленты",
|
||||||
|
"nav.history": "История",
|
||||||
|
"nav.users": "Пользователи",
|
||||||
|
"nav.settings": "Настройки",
|
||||||
|
"topbar.logout": "Выйти",
|
||||||
|
"theme.toggle": "Сменить тему",
|
||||||
|
|
||||||
|
"stats.feeds": "лент",
|
||||||
|
"stats.enabled": "активных",
|
||||||
|
"stats.failing": "с ошибками",
|
||||||
|
"stats.sent": "отправлено",
|
||||||
|
"stats.failed": "сбоев",
|
||||||
|
|
||||||
|
"chart.title": "Активность за 14 дней",
|
||||||
|
"chart.sent": "Отправлено",
|
||||||
|
"chart.failed": "Сбои",
|
||||||
|
"chart.empty": "Нет данных за период",
|
||||||
|
|
||||||
|
"feeds.heading": "RSS-ленты",
|
||||||
|
"feeds.checkAll": "↻ Проверить все",
|
||||||
|
"feeds.import": "⬆ Импорт OPML",
|
||||||
|
"feeds.export": "⬇ Экспорт OPML",
|
||||||
|
"feeds.add": "+ Добавить ленту",
|
||||||
|
"feeds.empty": "Пока нет ни одной ленты. Добавьте первую, чтобы начать получать уведомления.",
|
||||||
|
"feeds.never": "ещё не проверялась",
|
||||||
|
"feeds.noTopic": "— тема не задана —",
|
||||||
|
|
||||||
|
"history.heading": "История уведомлений",
|
||||||
|
"history.refresh": "↻ Обновить",
|
||||||
|
"history.clear": "Очистить",
|
||||||
|
"history.search": "Поиск по заголовку или ленте…",
|
||||||
|
"history.onlyErrors": "Только ошибки",
|
||||||
|
"history.empty": "История пуста.",
|
||||||
|
|
||||||
|
"users.heading": "Пользователи",
|
||||||
|
"users.add": "+ Добавить пользователя",
|
||||||
|
"users.admin": "👑 администратор",
|
||||||
|
"users.viewer": "👁 наблюдатель",
|
||||||
|
|
||||||
|
"settings.heading": "Настройки",
|
||||||
|
"settings.ntfy": "ntfy",
|
||||||
|
"settings.defaultServer": "Сервер ntfy по умолчанию",
|
||||||
|
"settings.defaultServerHint": "Используется для лент, у которых не задан собственный сервер.",
|
||||||
|
"settings.testPh": "тема для теста, напр. my-news",
|
||||||
|
"settings.testBtn": "Отправить тест",
|
||||||
|
"settings.check": "Проверка",
|
||||||
|
"settings.interval": "Интервал проверки по умолчанию (минуты)",
|
||||||
|
"settings.intervalHint": "Можно переопределить для каждой ленты отдельно.",
|
||||||
|
"settings.telegram": "Telegram",
|
||||||
|
"settings.tgEnable": "Включить доставку в Telegram",
|
||||||
|
"settings.tgToken": "Bot Token",
|
||||||
|
"settings.tgChat": "Chat ID",
|
||||||
|
"settings.tgHint": "Создайте бота через @BotFather, добавьте его в чат и укажите chat_id. Затем включите канал в нужных лентах.",
|
||||||
|
"settings.webhook": "Webhook",
|
||||||
|
"settings.whEnable": "Включить доставку через webhook",
|
||||||
|
"settings.whUrl": "URL webhook",
|
||||||
|
"settings.whHint": "POST с JSON: feed, feed_url, title, body, link, image.",
|
||||||
|
"settings.alerts": "Оповещения администратора",
|
||||||
|
"settings.alertEnable": "Уведомлять, если лента «упала»",
|
||||||
|
"settings.alertTopic": "Тема ntfy для алертов",
|
||||||
|
"settings.alertThreshold": "Порог (ошибок подряд)",
|
||||||
|
"settings.auth": "Авторизация",
|
||||||
|
"settings.authRequire": "Требовать вход в веб-панель",
|
||||||
|
"settings.authHint": "Учётные записи управляются во вкладке «Пользователи».",
|
||||||
|
"settings.save": "Сохранить настройки",
|
||||||
|
|
||||||
|
"modal.addFeed": "Добавить ленту",
|
||||||
|
"modal.editFeed": "Редактировать ленту",
|
||||||
|
"modal.cancel": "Отмена",
|
||||||
|
"modal.save": "Сохранить",
|
||||||
|
"feed.url": "URL ленты *",
|
||||||
|
"feed.title": "Название",
|
||||||
|
"feed.titleOpt": "(необязательно, определится автоматически)",
|
||||||
|
"feed.server": "Сервер ntfy",
|
||||||
|
"feed.serverHint": "(пусто = по умолчанию)",
|
||||||
|
"feed.topic": "Тема ntfy",
|
||||||
|
"feed.priv": "Приватный ntfy-сервер (авторизация)",
|
||||||
|
"feed.token": "Access token",
|
||||||
|
"feed.tokenHint": "(tk_…, приоритетнее логина)",
|
||||||
|
"feed.login": "Логин",
|
||||||
|
"feed.password": "Пароль",
|
||||||
|
"feed.priority": "Приоритет",
|
||||||
|
"feed.p1": "1 — минимальный",
|
||||||
|
"feed.p2": "2 — низкий",
|
||||||
|
"feed.p3": "3 — обычный",
|
||||||
|
"feed.p4": "4 — высокий",
|
||||||
|
"feed.p5": "5 — максимальный",
|
||||||
|
"feed.intervalMin": "Интервал, мин",
|
||||||
|
"feed.intervalHint": "(0 = общий)",
|
||||||
|
"feed.tags": "Теги / эмодзи",
|
||||||
|
"feed.commaHint": "(через запятую)",
|
||||||
|
"feed.filterInc": "Фильтр: только с этими словами",
|
||||||
|
"feed.filterExc": "Фильтр: исключить слова",
|
||||||
|
"feed.attach": "Прикреплять картинку",
|
||||||
|
"feed.dupTg": "Дублировать в Telegram",
|
||||||
|
"feed.toWebhook": "Отправлять в webhook",
|
||||||
|
"feed.enabled": "Лента включена",
|
||||||
|
"feed.preview": "👁 Предпросмотр",
|
||||||
|
"feed.previewLoading": "Загрузка…",
|
||||||
|
"feed.previewHint": "Введите URL и нажмите «Предпросмотр», чтобы увидеть последнюю запись.",
|
||||||
|
|
||||||
|
"user.addTitle": "Добавить пользователя",
|
||||||
|
"user.editTitle": "Редактировать пользователя",
|
||||||
|
"user.login": "Логин *",
|
||||||
|
"user.password": "Пароль",
|
||||||
|
"user.pwReq": "*",
|
||||||
|
"user.pwKeep": "(пусто = не менять)",
|
||||||
|
"user.role": "Роль",
|
||||||
|
"user.roleAdmin": "Администратор (полный доступ)",
|
||||||
|
"user.roleViewer": "Наблюдатель (только просмотр)",
|
||||||
|
|
||||||
|
"toast.feedDeleted": "Лента удалена",
|
||||||
|
"toast.feedAdded": "Лента добавлена",
|
||||||
|
"toast.feedUpdated": "Лента обновлена",
|
||||||
|
"toast.saved": "Сохранено",
|
||||||
|
"toast.deleted": "Удалён",
|
||||||
|
"toast.checkDone": "Проверка завершена",
|
||||||
|
"toast.historyCleared": "История очищена",
|
||||||
|
"toast.settingsSaved": "Настройки сохранены",
|
||||||
|
"toast.sentTo": "Отправлено в {dest}",
|
||||||
|
"toast.imported": "Импортировано {added} из {total}",
|
||||||
|
"toast.needTestTopic": "Укажите тему для теста",
|
||||||
|
"toast.needUrl": "Сначала укажите URL ленты",
|
||||||
|
|
||||||
|
"confirm.deleteFeed": "Удалить ленту «{name}»?",
|
||||||
|
"confirm.deleteUser": "Удалить пользователя «{name}»?",
|
||||||
|
"confirm.clearHistory": "Очистить всю историю?",
|
||||||
|
|
||||||
|
"status.init": "Инициализировано ({n} записей)",
|
||||||
|
"status.sent": "Отправлено {n} новых",
|
||||||
|
"status.sentSkip": "Отправлено {n} новых, пропущено {s}",
|
||||||
|
"status.filtered": "Без изменений (отфильтровано {s})",
|
||||||
|
"status.nochange": "Без изменений",
|
||||||
|
"status.parseError": "Ошибка: {msg}",
|
||||||
|
"status.sendError": "Ошибка отправки: {msg}",
|
||||||
|
"status.dash": "—",
|
||||||
|
|
||||||
|
"role.admin": "админ",
|
||||||
|
"role.viewer": "наблюдатель",
|
||||||
|
"login.subtitle": "Войдите, чтобы продолжить",
|
||||||
|
"login.user": "Логин",
|
||||||
|
"login.pass": "Пароль",
|
||||||
|
"login.submit": "Войти",
|
||||||
|
"login.error": "Неверный логин или пароль",
|
||||||
|
},
|
||||||
|
|
||||||
|
en: {
|
||||||
|
"nav.feeds": "Feeds",
|
||||||
|
"nav.history": "History",
|
||||||
|
"nav.users": "Users",
|
||||||
|
"nav.settings": "Settings",
|
||||||
|
"topbar.logout": "Log out",
|
||||||
|
"theme.toggle": "Toggle theme",
|
||||||
|
|
||||||
|
"stats.feeds": "feeds",
|
||||||
|
"stats.enabled": "active",
|
||||||
|
"stats.failing": "failing",
|
||||||
|
"stats.sent": "sent",
|
||||||
|
"stats.failed": "failed",
|
||||||
|
|
||||||
|
"chart.title": "Activity (last 14 days)",
|
||||||
|
"chart.sent": "Sent",
|
||||||
|
"chart.failed": "Failed",
|
||||||
|
"chart.empty": "No data for this period",
|
||||||
|
|
||||||
|
"feeds.heading": "RSS feeds",
|
||||||
|
"feeds.checkAll": "↻ Check all",
|
||||||
|
"feeds.import": "⬆ Import OPML",
|
||||||
|
"feeds.export": "⬇ Export OPML",
|
||||||
|
"feeds.add": "+ Add feed",
|
||||||
|
"feeds.empty": "No feeds yet. Add your first one to start receiving notifications.",
|
||||||
|
"feeds.never": "not checked yet",
|
||||||
|
"feeds.noTopic": "— no topic set —",
|
||||||
|
|
||||||
|
"history.heading": "Notification history",
|
||||||
|
"history.refresh": "↻ Refresh",
|
||||||
|
"history.clear": "Clear",
|
||||||
|
"history.search": "Search by title or feed…",
|
||||||
|
"history.onlyErrors": "Errors only",
|
||||||
|
"history.empty": "History is empty.",
|
||||||
|
|
||||||
|
"users.heading": "Users",
|
||||||
|
"users.add": "+ Add user",
|
||||||
|
"users.admin": "👑 administrator",
|
||||||
|
"users.viewer": "👁 viewer",
|
||||||
|
|
||||||
|
"settings.heading": "Settings",
|
||||||
|
"settings.ntfy": "ntfy",
|
||||||
|
"settings.defaultServer": "Default ntfy server",
|
||||||
|
"settings.defaultServerHint": "Used for feeds that don't define their own server.",
|
||||||
|
"settings.testPh": "topic to test, e.g. my-news",
|
||||||
|
"settings.testBtn": "Send test",
|
||||||
|
"settings.check": "Polling",
|
||||||
|
"settings.interval": "Default poll interval (minutes)",
|
||||||
|
"settings.intervalHint": "Can be overridden per feed.",
|
||||||
|
"settings.telegram": "Telegram",
|
||||||
|
"settings.tgEnable": "Enable Telegram delivery",
|
||||||
|
"settings.tgToken": "Bot Token",
|
||||||
|
"settings.tgChat": "Chat ID",
|
||||||
|
"settings.tgHint": "Create a bot via @BotFather, add it to a chat and set the chat_id. Then enable the channel on the feeds you want.",
|
||||||
|
"settings.webhook": "Webhook",
|
||||||
|
"settings.whEnable": "Enable webhook delivery",
|
||||||
|
"settings.whUrl": "Webhook URL",
|
||||||
|
"settings.whHint": "POST with JSON: feed, feed_url, title, body, link, image.",
|
||||||
|
"settings.alerts": "Admin alerts",
|
||||||
|
"settings.alertEnable": "Notify when a feed keeps failing",
|
||||||
|
"settings.alertTopic": "ntfy topic for alerts",
|
||||||
|
"settings.alertThreshold": "Threshold (consecutive errors)",
|
||||||
|
"settings.auth": "Authentication",
|
||||||
|
"settings.authRequire": "Require login to the web panel",
|
||||||
|
"settings.authHint": "Accounts are managed on the «Users» tab.",
|
||||||
|
"settings.save": "Save settings",
|
||||||
|
|
||||||
|
"modal.addFeed": "Add feed",
|
||||||
|
"modal.editFeed": "Edit feed",
|
||||||
|
"modal.cancel": "Cancel",
|
||||||
|
"modal.save": "Save",
|
||||||
|
"feed.url": "Feed URL *",
|
||||||
|
"feed.title": "Title",
|
||||||
|
"feed.titleOpt": "(optional, detected automatically)",
|
||||||
|
"feed.server": "ntfy server",
|
||||||
|
"feed.serverHint": "(empty = default)",
|
||||||
|
"feed.topic": "ntfy topic",
|
||||||
|
"feed.priv": "Private ntfy server (authentication)",
|
||||||
|
"feed.token": "Access token",
|
||||||
|
"feed.tokenHint": "(tk_…, takes precedence over login)",
|
||||||
|
"feed.login": "Username",
|
||||||
|
"feed.password": "Password",
|
||||||
|
"feed.priority": "Priority",
|
||||||
|
"feed.p1": "1 — min",
|
||||||
|
"feed.p2": "2 — low",
|
||||||
|
"feed.p3": "3 — default",
|
||||||
|
"feed.p4": "4 — high",
|
||||||
|
"feed.p5": "5 — max",
|
||||||
|
"feed.intervalMin": "Interval, min",
|
||||||
|
"feed.intervalHint": "(0 = global)",
|
||||||
|
"feed.tags": "Tags / emojis",
|
||||||
|
"feed.commaHint": "(comma separated)",
|
||||||
|
"feed.filterInc": "Filter: only with these words",
|
||||||
|
"feed.filterExc": "Filter: exclude words",
|
||||||
|
"feed.attach": "Attach image",
|
||||||
|
"feed.dupTg": "Mirror to Telegram",
|
||||||
|
"feed.toWebhook": "Send to webhook",
|
||||||
|
"feed.enabled": "Feed enabled",
|
||||||
|
"feed.preview": "👁 Preview",
|
||||||
|
"feed.previewLoading": "Loading…",
|
||||||
|
"feed.previewHint": "Enter a URL and click «Preview» to see the latest entry.",
|
||||||
|
|
||||||
|
"user.addTitle": "Add user",
|
||||||
|
"user.editTitle": "Edit user",
|
||||||
|
"user.login": "Username *",
|
||||||
|
"user.password": "Password",
|
||||||
|
"user.pwReq": "*",
|
||||||
|
"user.pwKeep": "(empty = keep current)",
|
||||||
|
"user.role": "Role",
|
||||||
|
"user.roleAdmin": "Administrator (full access)",
|
||||||
|
"user.roleViewer": "Viewer (read-only)",
|
||||||
|
|
||||||
|
"toast.feedDeleted": "Feed deleted",
|
||||||
|
"toast.feedAdded": "Feed added",
|
||||||
|
"toast.feedUpdated": "Feed updated",
|
||||||
|
"toast.saved": "Saved",
|
||||||
|
"toast.deleted": "Deleted",
|
||||||
|
"toast.checkDone": "Check complete",
|
||||||
|
"toast.historyCleared": "History cleared",
|
||||||
|
"toast.settingsSaved": "Settings saved",
|
||||||
|
"toast.sentTo": "Sent to {dest}",
|
||||||
|
"toast.imported": "Imported {added} of {total}",
|
||||||
|
"toast.needTestTopic": "Enter a topic to test",
|
||||||
|
"toast.needUrl": "Enter the feed URL first",
|
||||||
|
|
||||||
|
"confirm.deleteFeed": "Delete feed «{name}»?",
|
||||||
|
"confirm.deleteUser": "Delete user «{name}»?",
|
||||||
|
"confirm.clearHistory": "Clear the entire history?",
|
||||||
|
|
||||||
|
"status.init": "Initialized ({n} entries)",
|
||||||
|
"status.sent": "Sent {n} new",
|
||||||
|
"status.sentSkip": "Sent {n} new, skipped {s}",
|
||||||
|
"status.filtered": "No changes (filtered out {s})",
|
||||||
|
"status.nochange": "No changes",
|
||||||
|
"status.parseError": "Error: {msg}",
|
||||||
|
"status.sendError": "Send error: {msg}",
|
||||||
|
"status.dash": "—",
|
||||||
|
|
||||||
|
"role.admin": "admin",
|
||||||
|
"role.viewer": "viewer",
|
||||||
|
"login.subtitle": "Sign in to continue",
|
||||||
|
"login.user": "Username",
|
||||||
|
"login.pass": "Password",
|
||||||
|
"login.submit": "Sign in",
|
||||||
|
"login.error": "Wrong username or password",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLang() {
|
||||||
|
const l = localStorage.getItem("lang");
|
||||||
|
if (l === "ru" || l === "en") return l;
|
||||||
|
return (navigator.language || "en").startsWith("ru") ? "ru" : "en";
|
||||||
|
}
|
||||||
|
function setLang(lang) {
|
||||||
|
localStorage.setItem("lang", lang);
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
function t(key, params) {
|
||||||
|
let s = (I18N[getLang()] || I18N.en)[key] ?? key;
|
||||||
|
if (params) for (const k in params) s = s.replaceAll(`{${k}}`, params[k]);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function applyI18n(root = document) {
|
||||||
|
root.querySelectorAll("[data-i18n]").forEach(el => {
|
||||||
|
el.textContent = t(el.getAttribute("data-i18n"));
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-i18n-ph]").forEach(el => {
|
||||||
|
el.setAttribute("placeholder", t(el.getAttribute("data-i18n-ph")));
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-i18n-title]").forEach(el => {
|
||||||
|
el.setAttribute("title", t(el.getAttribute("data-i18n-title")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme + locale helpers shared across pages. */
|
||||||
|
function getTheme() {
|
||||||
|
return localStorage.getItem("theme") === "light" ? "light" : "dark";
|
||||||
|
}
|
||||||
|
function setTheme(theme) {
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
}
|
||||||
|
function localeTag() {
|
||||||
|
return getLang() === "ru" ? "ru-RU" : "en-US";
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0e1117;
|
||||||
|
--bg-soft: #161b22;
|
||||||
|
--bg-card: #1b2230;
|
||||||
|
--border: #2a3343;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #8b97a8;
|
||||||
|
--primary: #4f7cff;
|
||||||
|
--primary-hover: #3d68ec;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--radius: 14px;
|
||||||
|
--shadow: 0 8px 30px rgba(0, 0, 0, .35);
|
||||||
|
--topbar-bg: rgba(22, 27, 34, .7);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg: #f4f6fb;
|
||||||
|
--bg-soft: #ffffff;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--border: #dde3ec;
|
||||||
|
--text: #1b2230;
|
||||||
|
--muted: #5d6b7e;
|
||||||
|
--primary: #3d68ec;
|
||||||
|
--primary-hover: #2f56d4;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--success: #16a34a;
|
||||||
|
--warn: #d97706;
|
||||||
|
--shadow: 0 6px 22px rgba(40, 60, 100, .12);
|
||||||
|
--topbar-bg: rgba(255, 255, 255, .8);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 80% -10%, rgba(79, 124, 255, .14), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at -10% 10%, rgba(34, 197, 94, .08), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 { margin: 0; font-weight: 600; }
|
||||||
|
.muted { color: var(--muted); font-weight: 400; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* ---------- Topbar ---------- */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
background: var(--topbar-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 700; font-size: 18px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.logo { font-size: 22px; }
|
||||||
|
.tabs { display: flex; gap: 6px; margin-left: 8px; }
|
||||||
|
.tab {
|
||||||
|
background: none; border: none; color: var(--muted);
|
||||||
|
padding: 8px 16px; border-radius: 10px; cursor: pointer;
|
||||||
|
font-size: 15px; font-weight: 500; font-family: inherit; transition: .15s;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); background: rgba(255, 255, 255, .04); }
|
||||||
|
.tab.active { color: var(--text); background: rgba(79, 124, 255, .16); }
|
||||||
|
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 14px; }
|
||||||
|
#whoami { font-size: 13px; }
|
||||||
|
|
||||||
|
/* ---------- Layout ---------- */
|
||||||
|
.container { max-width: 960px; margin: 0 auto; padding: 32px 24px 80px; }
|
||||||
|
.tab-panel { display: none; animation: fade .25s ease; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 22px; flex-wrap: wrap; gap: 12px;
|
||||||
|
}
|
||||||
|
.panel-head h2 { font-size: 22px; }
|
||||||
|
.panel-head-actions { display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
.btn {
|
||||||
|
font-family: inherit; font-size: 14px; font-weight: 500;
|
||||||
|
padding: 9px 16px; border-radius: 10px; border: 1px solid transparent;
|
||||||
|
cursor: pointer; transition: .15s; text-decoration: none; display: inline-flex;
|
||||||
|
align-items: center; gap: 6px; color: var(--text); background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.btn:active { transform: none; }
|
||||||
|
.btn.primary { background: var(--primary); color: #fff; }
|
||||||
|
.btn.primary:hover { background: var(--primary-hover); }
|
||||||
|
.btn.ghost { background: transparent; border-color: var(--border); }
|
||||||
|
.btn.ghost:hover { background: rgba(255, 255, 255, .05); }
|
||||||
|
.btn.danger { background: transparent; border-color: rgba(239, 68, 68, .4); color: #ff9a9a; }
|
||||||
|
.btn.danger:hover { background: rgba(239, 68, 68, .12); }
|
||||||
|
.btn.block { width: 100%; justify-content: center; }
|
||||||
|
.btn.small { padding: 6px 12px; font-size: 13px; }
|
||||||
|
.icon-btn {
|
||||||
|
background: none; border: none; color: var(--muted); font-size: 18px;
|
||||||
|
cursor: pointer; padding: 4px 8px; border-radius: 8px;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { color: var(--text); background: rgba(255, 255, 255, .06); }
|
||||||
|
|
||||||
|
/* ---------- Cards (feeds) ---------- */
|
||||||
|
.cards { display: grid; gap: 14px; }
|
||||||
|
.feed-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.feed-card.disabled { opacity: .55; }
|
||||||
|
.feed-top { display: flex; align-items: flex-start; gap: 12px; }
|
||||||
|
.feed-title { font-weight: 600; font-size: 16px; word-break: break-word; }
|
||||||
|
.feed-url { font-size: 12.5px; color: var(--muted); word-break: break-all; margin-top: 2px; }
|
||||||
|
.feed-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||||
|
.chip {
|
||||||
|
font-size: 12px; padding: 3px 10px; border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, .06); color: var(--muted);
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
}
|
||||||
|
.chip.topic { background: rgba(79, 124, 255, .16); color: #aebfff; }
|
||||||
|
.chip.tg { background: rgba(34, 158, 217, .18); color: #7fd0f0; }
|
||||||
|
.feed-status { font-size: 12.5px; color: var(--muted); }
|
||||||
|
.feed-status .ok { color: var(--success); }
|
||||||
|
.feed-status .err { color: var(--danger); }
|
||||||
|
.feed-actions { display: flex; gap: 8px; margin-left: auto; }
|
||||||
|
|
||||||
|
/* badge dot */
|
||||||
|
.dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; }
|
||||||
|
.dot.on { background: var(--success); box-shadow: 0 0 8px rgba(34, 197, 94, .6); }
|
||||||
|
.dot.off { background: var(--muted); }
|
||||||
|
|
||||||
|
/* ---------- Empty ---------- */
|
||||||
|
.empty { text-align: center; padding: 70px 20px; color: var(--muted); }
|
||||||
|
.empty-icon { font-size: 52px; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* ---------- Forms ---------- */
|
||||||
|
label { display: block; font-size: 13.5px; font-weight: 500; margin-bottom: 14px; }
|
||||||
|
label small { font-weight: 400; }
|
||||||
|
input[type=text], input[type=url], input[type=number], input[type=password], select {
|
||||||
|
width: 100%; margin-top: 6px; padding: 10px 12px;
|
||||||
|
background: var(--bg-soft); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; color: var(--text); font-family: inherit; font-size: 14px;
|
||||||
|
transition: .15s;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79, 124, 255, .18); }
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
|
||||||
|
@media (max-width: 560px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 26px; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.settings-card h3 {
|
||||||
|
font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
|
||||||
|
color: var(--muted); margin: 22px 0 14px; padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-card h3:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
||||||
|
.form-actions { margin-top: 24px; display: flex; justify-content: flex-end; }
|
||||||
|
.inline-test { display: flex; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.inline-test input { margin-top: 0; }
|
||||||
|
.auth-fields { padding-left: 2px; }
|
||||||
|
|
||||||
|
/* switch */
|
||||||
|
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
|
.switch { appearance: none; width: 44px; height: 24px; border-radius: 999px;
|
||||||
|
background: var(--border); position: relative; cursor: pointer; transition: .2s; margin: 0; flex-shrink: 0; }
|
||||||
|
.switch::after { content: ''; position: absolute; width: 18px; height: 18px; border-radius: 50%;
|
||||||
|
background: #fff; top: 3px; left: 3px; transition: .2s; }
|
||||||
|
.switch:checked { background: var(--primary); }
|
||||||
|
.switch:checked::after { left: 23px; }
|
||||||
|
|
||||||
|
/* ---------- Modal ---------- */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0, 0, 0, .6);
|
||||||
|
backdrop-filter: blur(4px); display: flex; align-items: center;
|
||||||
|
justify-content: center; z-index: 50; padding: 20px; animation: fade .15s;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 18px; width: 100%; max-width: 540px; box-shadow: var(--shadow);
|
||||||
|
max-height: 90vh; display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--border); }
|
||||||
|
.modal-body { padding: 22px 24px; overflow-y: auto; }
|
||||||
|
.modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* ---------- Login ---------- */
|
||||||
|
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 18px; padding: 38px 34px; width: 100%; max-width: 380px;
|
||||||
|
box-shadow: var(--shadow); text-align: center;
|
||||||
|
}
|
||||||
|
.login-logo { font-size: 46px; margin-bottom: 10px; }
|
||||||
|
.login-card h1 { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
.login-card p { margin: 0 0 22px; }
|
||||||
|
.login-card label { text-align: left; }
|
||||||
|
.login-card .btn { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ---------- Alerts / toast ---------- */
|
||||||
|
.alert { padding: 10px 14px; border-radius: 10px; font-size: 13.5px; margin-bottom: 16px; text-align: left; }
|
||||||
|
.alert.error { background: rgba(239, 68, 68, .14); color: #ffb4b4; border: 1px solid rgba(239, 68, 68, .3); }
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border); color: var(--text);
|
||||||
|
padding: 12px 20px; border-radius: 12px; box-shadow: var(--shadow);
|
||||||
|
font-size: 14px; opacity: 0; transition: .25s; z-index: 100; max-width: 90vw;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
.toast.ok { border-color: rgba(34, 197, 94, .5); }
|
||||||
|
.toast.err { border-color: rgba(239, 68, 68, .5); }
|
||||||
|
|
||||||
|
/* ---------- Stats ---------- */
|
||||||
|
.stats { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 24px; }
|
||||||
|
.stat {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 12px 18px; min-width: 92px; text-align: center;
|
||||||
|
}
|
||||||
|
.stat b { display: block; font-size: 24px; font-weight: 700; }
|
||||||
|
.stat span { font-size: 12px; color: var(--muted); }
|
||||||
|
.stat.warn b { color: var(--warn); }
|
||||||
|
|
||||||
|
/* ---------- Details / advanced ---------- */
|
||||||
|
details.adv {
|
||||||
|
border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
padding: 4px 14px; margin-bottom: 14px; background: rgba(255, 255, 255, .02);
|
||||||
|
}
|
||||||
|
details.adv summary {
|
||||||
|
cursor: pointer; font-size: 13.5px; font-weight: 500; padding: 8px 0;
|
||||||
|
color: var(--muted); list-style: none;
|
||||||
|
}
|
||||||
|
details.adv summary::-webkit-details-marker { display: none; }
|
||||||
|
details.adv summary::before { content: "▸ "; }
|
||||||
|
details.adv[open] summary::before { content: "▾ "; }
|
||||||
|
details.adv[open] { padding-bottom: 8px; }
|
||||||
|
|
||||||
|
.switch-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; margin-top: 4px; }
|
||||||
|
@media (max-width: 560px) { .switch-grid { grid-template-columns: 1fr; } }
|
||||||
|
.switch-grid .switch-row { margin-bottom: 10px; }
|
||||||
|
|
||||||
|
/* ---------- History ---------- */
|
||||||
|
.history { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.history-row {
|
||||||
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--success); border-radius: 10px; padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.history-row.err { border-left-color: var(--danger); }
|
||||||
|
.history-icon { font-size: 16px; }
|
||||||
|
.history-main { flex: 1; min-width: 0; }
|
||||||
|
.history-title { font-weight: 500; font-size: 14.5px; word-break: break-word; }
|
||||||
|
.history-title a { color: var(--text); text-decoration: none; }
|
||||||
|
.history-title a:hover { color: var(--primary); text-decoration: underline; }
|
||||||
|
.history-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 4px; font-size: 12.5px; }
|
||||||
|
.history-sub .err { color: #ff9a9a; }
|
||||||
|
.history-time { font-size: 12px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---------- Language select / topbar controls ---------- */
|
||||||
|
.lang-select {
|
||||||
|
width: auto; margin: 0; padding: 6px 8px; font-size: 13px;
|
||||||
|
background: var(--bg-soft); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.login-controls { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 18px; }
|
||||||
|
|
||||||
|
/* ---------- Activity chart ---------- */
|
||||||
|
.chart-card {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 16px 18px 12px; margin-bottom: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.chart-head {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: 13px; color: var(--muted); margin-bottom: 10px; flex-wrap: wrap; gap: 8px;
|
||||||
|
}
|
||||||
|
.chart-legend { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.chart-legend .lg { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
||||||
|
.chart-legend .lg.sent { background: var(--success); }
|
||||||
|
.chart-legend .lg.failed { background: var(--danger); margin-left: 8px; }
|
||||||
|
#chart { width: 100%; }
|
||||||
|
.chart-svg { width: 100%; height: 90px; display: block; }
|
||||||
|
.chart-svg .bar-sent { fill: var(--success); }
|
||||||
|
.chart-svg .bar-fail { fill: var(--danger); }
|
||||||
|
.chart-svg rect { transition: opacity .15s; }
|
||||||
|
.chart-svg g:hover rect { opacity: .75; }
|
||||||
|
|
||||||
|
/* ---------- History toolbar ---------- */
|
||||||
|
.history-toolbar { display: flex; gap: 14px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
||||||
|
.history-toolbar input[type=search] { flex: 1; min-width: 200px; margin: 0; }
|
||||||
|
.check-inline { display: inline-flex; align-items: center; gap: 7px; margin: 0; font-size: 13.5px; white-space: nowrap; cursor: pointer; }
|
||||||
|
.check-inline input { width: 16px; height: 16px; margin: 0; accent-color: var(--primary); }
|
||||||
|
|
||||||
|
/* ---------- Notification preview ---------- */
|
||||||
|
.preview-block { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 14px; }
|
||||||
|
#preview-area { margin-top: 12px; }
|
||||||
|
.ntfy-preview {
|
||||||
|
background: var(--bg-soft); border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--primary); border-radius: 10px; padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.np-head { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
.np-title { font-weight: 600; font-size: 15px; margin-bottom: 6px; word-break: break-word; }
|
||||||
|
.np-body { font-size: 13.5px; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.ntfy-preview img { max-width: 100%; border-radius: 8px; margin-top: 10px; display: block; }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}RSS → ntfy{% endblock %}</title>
|
||||||
|
<script>
|
||||||
|
// Apply saved theme/lang before paint to avoid a flash.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var th = localStorage.getItem("theme") === "light" ? "light" : "dark";
|
||||||
|
document.documentElement.setAttribute("data-theme", th);
|
||||||
|
var l = localStorage.getItem("lang");
|
||||||
|
if (l !== "ru" && l !== "en")
|
||||||
|
l = (navigator.language || "en").indexOf("ru") === 0 ? "ru" : "en";
|
||||||
|
document.documentElement.lang = l;
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
<script src="/static/i18n.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}RSS → ntfy{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand"><span class="logo">📡</span> RSS → ntfy</div>
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab active" data-tab="feeds" data-i18n="nav.feeds">Ленты</button>
|
||||||
|
<button class="tab" data-tab="history" data-i18n="nav.history">История</button>
|
||||||
|
<button class="tab admin-only" data-tab="users" data-i18n="nav.users">Пользователи</button>
|
||||||
|
<button class="tab admin-only" data-tab="settings" data-i18n="nav.settings">Настройки</button>
|
||||||
|
</nav>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span id="whoami" class="muted"></span>
|
||||||
|
<select id="lang-select" class="lang-select">
|
||||||
|
<option value="ru">RU</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
</select>
|
||||||
|
<button class="icon-btn" id="theme-btn" data-i18n-title="theme.toggle">🌓</button>
|
||||||
|
<a class="btn ghost" href="/logout" id="logout-btn" data-i18n="topbar.logout" style="display:none">Выйти</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<!-- ===================== FEEDS ===================== -->
|
||||||
|
<section id="tab-feeds" class="tab-panel active">
|
||||||
|
<div id="stats" class="stats"></div>
|
||||||
|
<div id="chart-wrap" class="chart-card hidden">
|
||||||
|
<div class="chart-head">
|
||||||
|
<span data-i18n="chart.title">Активность за 14 дней</span>
|
||||||
|
<span class="chart-legend">
|
||||||
|
<i class="lg sent"></i><span data-i18n="chart.sent">Отправлено</span>
|
||||||
|
<i class="lg failed"></i><span data-i18n="chart.failed">Сбои</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="chart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2 data-i18n="feeds.heading">RSS-ленты</h2>
|
||||||
|
<div class="panel-head-actions">
|
||||||
|
<button class="btn ghost" id="check-all" data-i18n="feeds.checkAll">↻ Проверить все</button>
|
||||||
|
<button class="btn ghost admin-only" id="import-btn" data-i18n="feeds.import">⬆ Импорт OPML</button>
|
||||||
|
<button class="btn ghost" id="export-btn" data-i18n="feeds.export">⬇ Экспорт OPML</button>
|
||||||
|
<button class="btn primary admin-only" id="add-feed" data-i18n="feeds.add">+ Добавить ленту</button>
|
||||||
|
<input type="file" id="opml-file" accept=".opml,.xml,text/xml" hidden>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="feeds-list" class="cards"></div>
|
||||||
|
<div id="feeds-empty" class="empty hidden">
|
||||||
|
<div class="empty-icon">🗞️</div>
|
||||||
|
<p data-i18n="feeds.empty"></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== HISTORY ===================== -->
|
||||||
|
<section id="tab-history" class="tab-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2 data-i18n="history.heading">История уведомлений</h2>
|
||||||
|
<div class="panel-head-actions">
|
||||||
|
<button class="btn ghost" id="history-refresh" data-i18n="history.refresh">↻ Обновить</button>
|
||||||
|
<button class="btn danger admin-only" id="history-clear" data-i18n="history.clear">Очистить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-toolbar">
|
||||||
|
<input type="search" id="history-search" data-i18n-ph="history.search" placeholder="Поиск…">
|
||||||
|
<label class="check-inline"><input type="checkbox" id="history-errors">
|
||||||
|
<span data-i18n="history.onlyErrors">Только ошибки</span></label>
|
||||||
|
</div>
|
||||||
|
<div id="history-list" class="history"></div>
|
||||||
|
<div id="history-empty" class="empty hidden">
|
||||||
|
<div class="empty-icon">📭</div>
|
||||||
|
<p data-i18n="history.empty">История пуста.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== USERS ===================== -->
|
||||||
|
<section id="tab-users" class="tab-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2 data-i18n="users.heading">Пользователи</h2>
|
||||||
|
<button class="btn primary" id="add-user" data-i18n="users.add">+ Добавить пользователя</button>
|
||||||
|
</div>
|
||||||
|
<div id="users-list" class="cards"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== SETTINGS ===================== -->
|
||||||
|
<section id="tab-settings" class="tab-panel">
|
||||||
|
<div class="panel-head"><h2 data-i18n="settings.heading">Настройки</h2></div>
|
||||||
|
|
||||||
|
<form id="settings-form" class="settings-card">
|
||||||
|
<h3 data-i18n="settings.ntfy">ntfy</h3>
|
||||||
|
<label><span data-i18n="settings.defaultServer">Сервер ntfy по умолчанию</span>
|
||||||
|
<input type="text" name="default_ntfy_server" placeholder="https://ntfy.sh">
|
||||||
|
<small class="muted" data-i18n="settings.defaultServerHint"></small>
|
||||||
|
</label>
|
||||||
|
<div class="inline-test">
|
||||||
|
<input type="text" id="test-topic" data-i18n-ph="settings.testPh">
|
||||||
|
<button type="button" class="btn ghost" id="test-btn" data-i18n="settings.testBtn">Отправить тест</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 data-i18n="settings.check">Проверка</h3>
|
||||||
|
<label><span data-i18n="settings.interval">Интервал проверки по умолчанию (минуты)</span>
|
||||||
|
<input type="number" name="check_interval" min="1" value="5">
|
||||||
|
<small class="muted" data-i18n="settings.intervalHint"></small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h3 data-i18n="settings.telegram">Telegram</h3>
|
||||||
|
<label class="switch-row"><span data-i18n="settings.tgEnable">Включить доставку в Telegram</span>
|
||||||
|
<input type="checkbox" name="telegram_enabled" class="switch"></label>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label><span data-i18n="settings.tgToken">Bot Token</span>
|
||||||
|
<input type="text" name="telegram_token" placeholder="123456:ABC-..."></label>
|
||||||
|
<label><span data-i18n="settings.tgChat">Chat ID</span>
|
||||||
|
<input type="text" name="telegram_chat_id" placeholder="-1001234567890"></label>
|
||||||
|
</div>
|
||||||
|
<small class="muted" data-i18n="settings.tgHint"></small>
|
||||||
|
|
||||||
|
<h3 data-i18n="settings.webhook">Webhook</h3>
|
||||||
|
<label class="switch-row"><span data-i18n="settings.whEnable">Включить доставку через webhook</span>
|
||||||
|
<input type="checkbox" name="webhook_enabled" class="switch"></label>
|
||||||
|
<label><span data-i18n="settings.whUrl">URL webhook</span>
|
||||||
|
<input type="text" name="webhook_url" placeholder="https://example.com/hook">
|
||||||
|
<small class="muted" data-i18n="settings.whHint"></small></label>
|
||||||
|
|
||||||
|
<h3 data-i18n="settings.alerts">Оповещения администратора</h3>
|
||||||
|
<label class="switch-row"><span data-i18n="settings.alertEnable">Уведомлять, если лента «упала»</span>
|
||||||
|
<input type="checkbox" name="alerts_enabled" class="switch"></label>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label><span data-i18n="settings.alertTopic">Тема ntfy для алертов</span>
|
||||||
|
<input type="text" name="alert_topic" placeholder="rss-alerts"></label>
|
||||||
|
<label><span data-i18n="settings.alertThreshold">Порог (ошибок подряд)</span>
|
||||||
|
<input type="number" name="alert_threshold" min="1" value="3"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 data-i18n="settings.auth">Авторизация</h3>
|
||||||
|
<label class="switch-row"><span data-i18n="settings.authRequire">Требовать вход в веб-панель</span>
|
||||||
|
<input type="checkbox" name="auth_enabled" class="switch"></label>
|
||||||
|
<small class="muted" data-i18n="settings.authHint"></small>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn primary" data-i18n="settings.save">Сохранить настройки</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ===================== FEED MODAL ===================== -->
|
||||||
|
<div id="modal" class="modal-backdrop hidden">
|
||||||
|
<form class="modal" id="feed-form">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 id="modal-title" data-i18n="modal.addFeed">Добавить ленту</h3>
|
||||||
|
<button type="button" class="icon-btn" id="modal-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="id">
|
||||||
|
<label><span data-i18n="feed.url">URL ленты *</span>
|
||||||
|
<input type="url" name="url" placeholder="https://example.com/feed.xml" required>
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="feed.title">Название</span> <small class="muted" data-i18n="feed.titleOpt"></small>
|
||||||
|
<input type="text" name="title">
|
||||||
|
</label>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label><span data-i18n="feed.server">Сервер ntfy</span> <small class="muted" data-i18n="feed.serverHint"></small>
|
||||||
|
<input type="text" name="ntfy_server" placeholder="https://ntfy.sh"></label>
|
||||||
|
<label><span data-i18n="feed.topic">Тема ntfy</span>
|
||||||
|
<input type="text" name="ntfy_topic" placeholder="my-news"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="adv">
|
||||||
|
<summary data-i18n="feed.priv">Приватный ntfy-сервер (авторизация)</summary>
|
||||||
|
<label><span data-i18n="feed.token">Access token</span> <small class="muted" data-i18n="feed.tokenHint"></small>
|
||||||
|
<input type="text" name="ntfy_token" placeholder="tk_..."></label>
|
||||||
|
<div class="grid-2">
|
||||||
|
<label><span data-i18n="feed.login">Логин</span>
|
||||||
|
<input type="text" name="ntfy_username" autocomplete="off"></label>
|
||||||
|
<label><span data-i18n="feed.password">Пароль</span>
|
||||||
|
<input type="password" name="ntfy_password" autocomplete="new-password"></label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<label><span data-i18n="feed.priority">Приоритет</span>
|
||||||
|
<select name="priority">
|
||||||
|
<option value="1" data-i18n="feed.p1"></option>
|
||||||
|
<option value="2" data-i18n="feed.p2"></option>
|
||||||
|
<option value="3" selected data-i18n="feed.p3"></option>
|
||||||
|
<option value="4" data-i18n="feed.p4"></option>
|
||||||
|
<option value="5" data-i18n="feed.p5"></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="feed.intervalMin">Интервал, мин</span> <small class="muted" data-i18n="feed.intervalHint"></small>
|
||||||
|
<input type="number" name="interval" min="0" value="0"></label>
|
||||||
|
</div>
|
||||||
|
<label><span data-i18n="feed.tags">Теги / эмодзи</span> <small class="muted" data-i18n="feed.commaHint"></small>
|
||||||
|
<input type="text" name="tags" placeholder="newspaper,fire"></label>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<label><span data-i18n="feed.filterInc">Фильтр: только с этими словами</span> <small class="muted" data-i18n="feed.commaHint"></small>
|
||||||
|
<input type="text" name="filter_include" placeholder="python, ai"></label>
|
||||||
|
<label><span data-i18n="feed.filterExc">Фильтр: исключить слова</span> <small class="muted" data-i18n="feed.commaHint"></small>
|
||||||
|
<input type="text" name="filter_exclude" placeholder="sponsored"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="switch-grid">
|
||||||
|
<label class="switch-row"><span data-i18n="feed.attach">Прикреплять картинку</span>
|
||||||
|
<input type="checkbox" name="attach_image" class="switch" checked></label>
|
||||||
|
<label class="switch-row"><span data-i18n="feed.dupTg">Дублировать в Telegram</span>
|
||||||
|
<input type="checkbox" name="to_telegram" class="switch"></label>
|
||||||
|
<label class="switch-row"><span data-i18n="feed.toWebhook">Отправлять в webhook</span>
|
||||||
|
<input type="checkbox" name="to_webhook" class="switch"></label>
|
||||||
|
<label class="switch-row"><span data-i18n="feed.enabled">Лента включена</span>
|
||||||
|
<input type="checkbox" name="enabled" class="switch" checked></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-block">
|
||||||
|
<button type="button" class="btn ghost small" id="preview-btn" data-i18n="feed.preview">👁 Предпросмотр</button>
|
||||||
|
<div id="preview-area"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button type="button" class="btn ghost" id="modal-cancel" data-i18n="modal.cancel">Отмена</button>
|
||||||
|
<button type="submit" class="btn primary" data-i18n="modal.save">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== USER MODAL ===================== -->
|
||||||
|
<div id="user-modal" class="modal-backdrop hidden">
|
||||||
|
<form class="modal" id="user-form">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 id="user-modal-title" data-i18n="user.addTitle">Добавить пользователя</h3>
|
||||||
|
<button type="button" class="icon-btn" id="user-modal-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="id">
|
||||||
|
<label><span data-i18n="user.login">Логин *</span>
|
||||||
|
<input type="text" name="username" autocomplete="off" required></label>
|
||||||
|
<label><span data-i18n="user.password">Пароль</span> <small class="muted" id="pw-hint"></small>
|
||||||
|
<input type="password" name="password" autocomplete="new-password"></label>
|
||||||
|
<label><span data-i18n="user.role">Роль</span>
|
||||||
|
<select name="role">
|
||||||
|
<option value="admin" data-i18n="user.roleAdmin"></option>
|
||||||
|
<option value="viewer" data-i18n="user.roleViewer"></option>
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button type="button" class="btn ghost" id="user-modal-cancel" data-i18n="modal.cancel">Отмена</button>
|
||||||
|
<button type="submit" class="btn primary" data-i18n="modal.save">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}<script src="/static/app.js"></script>{% endblock %}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}RSS → ntfy{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="login-wrap">
|
||||||
|
<form class="login-card" method="post" action="/login">
|
||||||
|
<div class="login-logo">📡</div>
|
||||||
|
<h1>RSS → ntfy</h1>
|
||||||
|
<p class="muted" data-i18n="login.subtitle">Войдите, чтобы продолжить</p>
|
||||||
|
{% if error %}<div class="alert error" data-i18n="login.error">{{ error }}</div>{% endif %}
|
||||||
|
<label><span data-i18n="login.user">Логин</span>
|
||||||
|
<input type="text" name="username" autocomplete="username" required autofocus>
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="login.pass">Пароль</span>
|
||||||
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn primary block" data-i18n="login.submit">Войти</button>
|
||||||
|
<div class="login-controls">
|
||||||
|
<button type="button" class="icon-btn" id="theme-btn" data-i18n-title="theme.toggle">🌓</button>
|
||||||
|
<select id="lang-select" class="lang-select">
|
||||||
|
<option value="ru">RU</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
applyI18n();
|
||||||
|
const ls = document.getElementById("lang-select");
|
||||||
|
ls.value = getLang();
|
||||||
|
ls.onchange = () => { setLang(ls.value); applyI18n(); };
|
||||||
|
document.getElementById("theme-btn").onclick = () =>
|
||||||
|
setTheme(getTheme() === "dark" ? "light" : "dark");
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
rss-ntfy:
|
||||||
|
build: .
|
||||||
|
image: rss-ntfy:latest
|
||||||
|
container_name: rss-ntfy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- rss_ntfy_data:/data
|
||||||
|
environment:
|
||||||
|
# Default ntfy server for feeds without their own.
|
||||||
|
DEFAULT_NTFY_SERVER: "https://ntfy.sh"
|
||||||
|
# Feed poll interval in minutes (initial value; editable in the UI).
|
||||||
|
DEFAULT_CHECK_INTERVAL: "5"
|
||||||
|
# Bootstrap admin credentials (used only on first start). Change these!
|
||||||
|
ADMIN_USERNAME: "admin"
|
||||||
|
ADMIN_PASSWORD: "admin"
|
||||||
|
# Optional: set a fixed cookie secret. If omitted one is generated and
|
||||||
|
# persisted in the data volume.
|
||||||
|
# SECRET_KEY: "change-me-to-a-long-random-string"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rss_ntfy_data:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
sqlmodel==0.0.22
|
||||||
|
feedparser==6.0.11
|
||||||
|
httpx==0.28.1
|
||||||
|
APScheduler==3.11.0
|
||||||
|
Jinja2==3.1.5
|
||||||
|
python-multipart==0.0.20
|
||||||
|
itsdangerous==2.2.0
|
||||||
Reference in New Issue
Block a user