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

Features: feed CRUD, per-feed ntfy target (incl. private servers),
Telegram/webhook channels, keyword filters, image attachments,
per-feed intervals, OPML import/export, notification history & stats,
users with roles, admin alerts, RU/EN i18n, light/dark theme,
notification preview, history search, activity chart. Dockerized.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dimon
2026-06-02 21:11:57 +08:00
commit bf52bc3079
28 changed files with 3396 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
"""Database engine, session helpers, bootstrap and lightweight migration."""
from __future__ import annotations
from contextlib import contextmanager
from typing import Iterator
from sqlalchemy import inspect, text
from sqlmodel import Session, SQLModel, create_engine, select
from . import config
from .auth import hash_password
from .models import Settings, User
engine = create_engine(
config.DATABASE_URL,
echo=False,
connect_args={"check_same_thread": False},
)
def _migrate() -> None:
"""Add any model columns missing from existing tables (SQLite ALTER ADD).
Keeps simple deployments upgradeable without a migration framework.
New columns always have defaults, so a plain ADD COLUMN is sufficient.
"""
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())
type_map = {"INTEGER": "INTEGER", "BOOLEAN": "BOOLEAN", "VARCHAR": "VARCHAR", "DATETIME": "DATETIME"}
with engine.begin() as conn:
for table in SQLModel.metadata.sorted_tables:
if table.name not in existing_tables:
continue
have = {c["name"] for c in inspector.get_columns(table.name)}
for column in table.columns:
if column.name in have:
continue
col_type = type_map.get(
column.type.__class__.__name__.upper(), "VARCHAR"
)
default = column.default.arg if column.default is not None else None
if isinstance(default, bool):
default_sql = "1" if default else "0"
elif isinstance(default, (int, float)):
default_sql = str(default)
elif isinstance(default, str):
default_sql = f"'{default}'"
else:
default_sql = "NULL"
conn.execute(
text(
f'ALTER TABLE "{table.name}" '
f'ADD COLUMN "{column.name}" {col_type} DEFAULT {default_sql}'
)
)
def init_db() -> None:
"""Create tables, run migration, ensure settings + admin user exist."""
SQLModel.metadata.create_all(engine)
_migrate()
with Session(engine) as session:
if session.get(Settings, 1) is None:
session.add(
Settings(
id=1,
default_ntfy_server=config.DEFAULT_NTFY_SERVER,
check_interval=config.DEFAULT_CHECK_INTERVAL,
auth_enabled=False,
)
)
session.commit()
# Bootstrap the first admin account if no users exist.
if not session.exec(select(User)).first():
session.add(
User(
username=config.ADMIN_USERNAME,
password_hash=hash_password(config.ADMIN_PASSWORD),
role="admin",
)
)
session.commit()
def get_settings(session: Session) -> Settings:
settings = session.get(Settings, 1)
if settings is None: # safety net
settings = Settings(id=1)
session.add(settings)
session.commit()
session.refresh(settings)
return settings
@contextmanager
def session_scope() -> Iterator[Session]:
session = Session(engine)
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def get_session() -> Iterator[Session]:
"""FastAPI dependency."""
with Session(engine) as session:
yield session
__all__ = [
"engine",
"init_db",
"get_settings",
"get_session",
"session_scope",
"select",
]