RSS → ntfy bridge with modern web UI
build-and-push / docker (push) Has been cancelled

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:
dimon
2026-06-02 21:11:57 +08:00
commit bf52bc3079
28 changed files with 3396 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
__pycache__/
*.pyc
*.pyo
.git/
.gitignore
.venv/
venv/
env/
data/
*.db
*.sqlite3
.env
.DS_Store
README.md
.idea/
.vscode/
+17
View File
@@ -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
+5
View File
@@ -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
+45
View File
@@ -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
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
.venv/
venv/
env/
.env
data/
*.db
*.sqlite3
.DS_Store
.idea/
.vscode/
.claude/
+116
View File
@@ -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
View File
@@ -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"]
+131
View File
@@ -0,0 +1,131 @@
# 📡 RSS → ntfy
Лёгкое приложение на Python (FastAPI), которое следит за RSS/Atom-лентами и при
появлении новых записей рассылает их в [ntfy](https://ntfy.sh), Telegram и/или
через webhook. Управление — через современную веб-панель.
![tech](https://img.shields.io/badge/FastAPI-009688?logo=fastapi&logoColor=white)
![tech](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)
![tech](https://img.shields.io/badge/Python-3.12-3776AB?logo=python&logoColor=white)
## Возможности
**Основное**
- ✅ Добавление, редактирование и удаление 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 — используйте свободно.
+3
View File
@@ -0,0 +1,3 @@
"""RSS → ntfy bridge application."""
__version__ = "1.0.0"
+34
View File
@@ -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
View File
@@ -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)
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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"
+481
View File
@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>
&nbsp;·&nbsp; ${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();
+336
View File
@@ -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";
}
+318
View File
@@ -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; }
+30
View File
@@ -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>
+254
View File
@@ -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&nbsp;&nbsp;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 %}
+36
View File
@@ -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 %}
+24
View File
@@ -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:
+9
View File
@@ -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