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