commit bf52bc30793ba0fa6b423c0311f1a9d868186aa6 Author: dimon Date: Tue Jun 2 21:11:57 2026 +0800 RSS → ntfy bridge with modern web UI 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76e3da3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +__pycache__/ +*.pyc +*.pyo +.git/ +.gitignore +.venv/ +venv/ +env/ +data/ +*.db +*.sqlite3 +.env +.DS_Store +README.md +.idea/ +.vscode/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2a1236a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..eff8a2a --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..9d27037 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -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//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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1c38fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +env/ +.env +data/ +*.db +*.sqlite3 +.DS_Store +.idea/ +.vscode/ +.claude/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..b8b4790 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,116 @@ +# 🚀 Развёртывание в Docker через Gitea + +Проект хранится в Gitea (`http://192.168.1.171:3000`). Ниже — как поднять его в +Docker на удалённом локальном хосте (в той же сети). Замените `` на имя +вашего пользователя/организации в Gitea. + +--- + +## Вариант A. Клонирование + Docker Compose (рекомендуется) + +Самый простой путь — собрать образ прямо на целевом хосте из исходников. + +На удалённом хосте (Linux с установленными `git`, `docker`, `docker compose`): + +```bash +# 1. Склонировать репозиторий из Gitea +git clone http://192.168.1.171:3000//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://:8000`. + +> Приватный репозиторий? Используйте токен в URL: +> `git clone http://:@192.168.1.171:3000//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//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//rss-ntfy:latest +``` + +Или через Compose — создайте `docker-compose.prod.yml`: + +```yaml +services: + rss-ntfy: + image: 192.168.1.171:3000//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` и ключ подписи сессий). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..325208c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..80a94ba --- /dev/null +++ b/README.md @@ -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 — используйте свободно. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..efbe336 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +"""RSS → ntfy bridge application.""" + +__version__ = "1.0.0" diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..7368215 --- /dev/null +++ b/app/auth.py @@ -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 diff --git a/app/checker.py b/app/checker.py new file mode 100644 index 0000000..18a6ca2 --- /dev/null +++ b/app/checker.py @@ -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']+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) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1dd41a4 --- /dev/null +++ b/app/config.py @@ -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") diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..091167a --- /dev/null +++ b/app/database.py @@ -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", +] diff --git a/app/delivery.py b/app/delivery.py new file mode 100644 index 0000000..70c5c2e --- /dev/null +++ b/app/delivery.py @@ -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"{_esc(msg.title)}" + if msg.source: + text = f"📡 {_esc(msg.source)}\n{text}" + if msg.body: + text += f"\n\n{_esc(msg.body[:600])}" + if msg.link: + text += f'\n\nОткрыть →' + + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.post( + f"https://api.telegram.org/bot{token}/sendMessage", + json={ + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": False, + }, + ) + resp.raise_for_status() + + +async def _send_webhook(settings: Settings, feed: Feed, msg: Message) -> None: + url = settings.webhook_url.strip() + if not url: + raise ValueError("Webhook URL не задан") + payload = { + "feed": msg.source, + "feed_url": feed.url, + "title": msg.title, + "body": msg.body, + "link": msg.link, + "image": msg.image, + } + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.post(url, json=payload) + resp.raise_for_status() + + +def _esc(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResult: + """Send a message across every channel enabled for this feed.""" + result = DispatchResult() + server = feed.ntfy_server.strip() or settings.default_ntfy_server + full_title = f"{msg.source}: {msg.title}" if msg.source else msg.title + + # --- ntfy (default channel; requires a topic) --- + if feed.ntfy_topic.strip(): + try: + await ntfy.publish( + server=server, + topic=feed.ntfy_topic, + title=full_title, + message=msg.body or "(нет описания)", + click=msg.link, + tags=feed.tags, + priority=feed.priority, + attach=msg.image if feed.attach_image else "", + token=feed.ntfy_token, + username=feed.ntfy_username, + password=feed.ntfy_password, + ) + result.channels.append("ntfy") + except Exception as exc: # noqa: BLE001 + result.errors.append(f"ntfy: {exc}") + + # --- Telegram --- + if feed.to_telegram and settings.telegram_enabled: + try: + await _send_telegram(settings, msg) + result.channels.append("telegram") + except Exception as exc: # noqa: BLE001 + result.errors.append(f"telegram: {exc}") + + # --- Webhook --- + if feed.to_webhook and settings.webhook_enabled: + try: + await _send_webhook(settings, feed, msg) + result.channels.append("webhook") + except Exception as exc: # noqa: BLE001 + result.errors.append(f"webhook: {exc}") + + return result + + +async def send_admin_alert(settings: Settings, text: str) -> None: + """Best-effort health alert to the admin ntfy topic.""" + if not settings.alerts_enabled or not settings.alert_topic.strip(): + return + try: + await ntfy.publish( + server=settings.default_ntfy_server, + topic=settings.alert_topic, + title="RSS to ntfy — alert", + message=text, + tags="warning", + priority=4, + ) + except Exception as exc: # noqa: BLE001 + log.warning("admin alert failed: %s", exc) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8ea7bf4 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..a9a221e --- /dev/null +++ b/app/models.py @@ -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 diff --git a/app/ntfy.py b/app/ntfy.py new file mode 100644 index 0000000..34a19f1 --- /dev/null +++ b/app/ntfy.py @@ -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() diff --git a/app/opml.py b/app/opml.py new file mode 100644 index 0000000..a6a9d17 --- /dev/null +++ b/app/opml.py @@ -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 '\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 diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..586786d --- /dev/null +++ b/app/scheduler.py @@ -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 diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..bc3215b --- /dev/null +++ b/app/schemas.py @@ -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" diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..68cce4b --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,481 @@ +"use strict"; + +const $ = (sel, root = document) => root.querySelector(sel); +const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; + +let ME = { role: "admin", auth_enabled: false }; + +// ---------- API helper ---------- +async function api(method, url, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + const res = await fetch(url, opts); + if (res.status === 401) { location.href = "/login"; throw new Error("auth"); } + const data = res.headers.get("content-type")?.includes("json") + ? await res.json() : null; + if (!res.ok) throw new Error(data?.detail || `Error ${res.status}`); + return data; +} + +// ---------- Toast ---------- +let toastTimer; +function toast(msg, kind = "ok") { + const el = $("#toast"); + el.textContent = msg; + el.className = `toast show ${kind}`; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.className = "toast hidden"; }, 3400); +} + +// ---------- utils ---------- +function escapeHtml(str) { + return String(str ?? "").replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} +function fmtDate(iso) { + if (!iso) return t("feeds.never"); + return new Date(iso).toLocaleString(localeTag(), + { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }); +} + +// Localize a status code emitted by the backend (e.g. "sent:3:1"). +function formatStatus(code) { + if (!code) return t("status.dash"); + const i = code.indexOf(":"); + const head = i === -1 ? code : code.slice(0, i); + const rest = i === -1 ? "" : code.slice(i + 1); + switch (head) { + case "init": return t("status.init", { n: rest }); + case "sent": { + const [n, s] = rest.split(":"); + return s ? t("status.sentSkip", { n, s }) : t("status.sent", { n }); + } + case "filtered": return t("status.filtered", { s: rest }); + case "nochange": return t("status.nochange"); + case "parse_error": return t("status.parseError", { msg: rest }); + case "send_error": return t("status.sendError", { msg: rest }); + default: return code; + } +} +function isErrorStatus(code) { + return /^(parse_error|send_error)/.test(code || ""); +} + +// ---------- Stats + chart ---------- +async function loadStats() { + try { + const s = await api("GET", "/api/stats"); + $("#stats").innerHTML = ` +
${s.feeds_total}${t("stats.feeds")}
+
${s.feeds_enabled}${t("stats.enabled")}
+
${s.feeds_failing}${t("stats.failing")}
+
${s.notifications_sent}${t("stats.sent")}
+
${s.notifications_failed}${t("stats.failed")}
`; + } 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 += `${escapeHtml(title)}`; + bars += ``; + if (failH > 0) + bars += ``; + bars += ``; + }); + $("#chart").innerHTML = + `${bars}`; +} + +// ---------- Feeds ---------- +function feedCard(f) { + const el = document.createElement("div"); + el.className = "feed-card" + (f.enabled ? "" : " disabled"); + const chips = []; + chips.push(`📨 ${escapeHtml(f.ntfy_topic || t("feeds.noTopic"))}`); + if (f.ntfy_server) chips.push(`🖥️ ${escapeHtml(f.ntfy_server)}`); + if (f.ntfy_token || f.ntfy_username) chips.push(`🔐 auth`); + chips.push(`⚡ P${f.priority}`); + if (f.interval) chips.push(`⏱ ${f.interval}m`); + if (f.to_telegram) chips.push(`✈️ TG`); + if (f.to_webhook) chips.push(`🔗 hook`); + if (f.filter_include || f.filter_exclude) chips.push(`🧩`); + if (f.tags) chips.push(`🏷️ ${escapeHtml(f.tags)}`); + + const admin = ME.role === "admin"; + el.innerHTML = ` +
+ +
+
${escapeHtml(f.title || f.url)}
+
${escapeHtml(f.url)}
+
${chips.join("")}
+
+
+ + ${admin ? ` + ` : ""} +
+
+
+ ${escapeHtml(formatStatus(f.last_status))} +  ·  ${fmtDate(f.last_checked)} +
`; + + $('[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 = `
${t("feed.previewLoading")}
`; + 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 ? `` : ""; + area.innerHTML = ` +
+
📡 ${escapeHtml(p.source || feedForm.title.value || "")}
+
${escapeHtml(p.title)}
+
${escapeHtml(p.body || "")}
+ ${img} +
`; + } catch (err) { + area.innerHTML = `
${escapeHtml(err.message)}
`; + } +}; + +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 => `${escapeHtml(c)}`).join("") + : ""; + el.innerHTML = ` +
${n.ok ? "✅" : "⚠️"}
+
+
${n.link + ? `${escapeHtml(n.title)}` + : escapeHtml(n.title)}
+
+ ${escapeHtml(n.feed_title || "")} ${channels} + ${n.detail ? `${escapeHtml(n.detail)}` : ""} +
+
+
${fmtDate(n.created_at)}
`; + 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 = ` +
+ +
+
${escapeHtml(u.username)}
+
+ ${u.role === "admin" ? t("users.admin") : t("users.viewer")}
+
+
+ + +
+
`; + $('[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(); diff --git a/app/static/i18n.js b/app/static/i18n.js new file mode 100644 index 0000000..8b4b7c7 --- /dev/null +++ b/app/static/i18n.js @@ -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"; +} diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..f4f1b18 --- /dev/null +++ b/app/static/style.css @@ -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; } diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..8e7365b --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,30 @@ + + + + + + {% block title %}RSS → ntfy{% endblock %} + + + + + + + + {% block body %}{% endblock %} + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..ed3e0d6 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,254 @@ +{% extends "base.html" %} +{% block title %}RSS → ntfy{% endblock %} +{% block body %} +
+
RSS → ntfy
+ +
+ + + + +
+
+ +
+ +
+
+ +
+

RSS-ленты

+
+ + + + + +
+
+
+ +
+ + +
+
+

История уведомлений

+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+

Пользователи

+ +
+
+
+ + +
+

Настройки

+ +
+

ntfy

+ +
+ + +
+ +

Проверка

+ + +

Telegram

+ +
+ + +
+ + +

Webhook

+ + + +

Оповещения администратора

+ +
+ + +
+ +

Авторизация

+ + + +
+ +
+
+
+
+ + + + + + + + +{% endblock %} + +{% block scripts %}{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..c7e83e0 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}RSS → ntfy{% endblock %} +{% block body %} + +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d2a65b --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a6abdcb --- /dev/null +++ b/requirements.txt @@ -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