diff --git a/CLAUDE.md b/CLAUDE.md index 080d62d..38fae22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,8 @@ - **FastAPI** для API (не Django) — обоснование описать в задаче #32 при проектировании API. - **Админ-панель** — SPA на Vue 3 с Gnexus UI Kit; раздаётся как статика контейнером FastAPI. Всё конфигурирование проекта — через админку, UI первичен для настроек. Минимум своего, максимум из кита. - **Доступ к админке** — роли gnexus-auth (SSO): настраивать Synapse — только `admin` и выше (админ-API под 403). Пользователь роли `user` **входит в приложение** с ограниченным личным разделом «Мои события» (`/api/v1/me`; в админку не пускается ни сервером, ни роутером SPA). Своих паролей Synapse не хранит. -- **Многопользовательность (2026-10-03)**: «о ком событие» — конвенция `payload.user_id` (= `sub` gnexus-auth; контракт v1 цел, получателей в конверте нет). Что видит пользователь — решают правила: новое действие-канал `user` (личный лог, адресат из `payload.user_id`; правило без него — событие «только для админа»). Позже тот же канал даст push (#29). В OAuth-колбэке токен выдаётся любому аутентифицированному (revoke не-админу больше не нужен). +- **Многопользовательность (2026-10-03)**: «о ком событие» — конвенция `payload.user_id` (= `sub` gnexus-auth; контракт v1 цел, получателей в конверте нет). Что видит пользователь — решают правила: целевые каналы `user` (личный лог) и `push` (web-push) без цели, адресат из `payload.user_id`; правило без них — событие «только для админа». Привязка «user_id ↔ адрес канала» для push — самопользовательская: браузер подписывается в разделе «Настройки» (docs/06). Остался открытый вопрос №3 по tg chat_id/email opt-in. В OAuth-колбэке токен выдаётся любому аутентифицированному (revoke не-админу больше не нужен). +- **Настройки + PWA (2026-10-03)**: редактируемые параметры — реестр `app/settings_registry.py`, дефолт из `.env`, оверрайды в таблице `app_settings` через админку (применяются без перезапуска; docs/06). Секреты (VAPID private, тг-токен, SMTP-пароль) — в БД write-only: записываются через UI, никогда не возвращаются API, читает только воркер; исключение из правила «секреты только в .env», согласовано владельцем. Канал `push` реализован: pywebpush (DER base64url ключи, 404/410 → подписка удаляется), таблица `push_subscriptions`, PWA (manifest + sw.js) в дистрибутиве SPA. ## Инфраструктура diff --git a/alembic/versions/20261003_0006_push_settings.py b/alembic/versions/20261003_0006_push_settings.py new file mode 100644 index 0000000..f265807 --- /dev/null +++ b/alembic/versions/20261003_0006_push_settings.py @@ -0,0 +1,56 @@ +""" Канал push + настройки: таблицы app_settings и push_subscriptions, ck_channel с 'push' + +Revision ID: e2f6a9b3c4d7 +Revises: a1c4e7d52b98 +Create Date: 2026-10-03 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "e2f6a9b3c4d7" +down_revision = "a1c4e7d52b98" +branch_labels = None +depends_on = None + +CHANNEL_WITH_PUSH = "channel IN ('telegram', 'email', 's2s', 'internal_log', 'user', 'push')" +CHANNEL_NO_PUSH = "channel IN ('telegram', 'email', 's2s', 'internal_log', 'user')" + +CHECK_TABLES = ("routing_rule_actions", "channel_targets", "deliveries") + + +def upgrade() -> None: + # ck_channel есть на трёх таблицах (см. 0004) — обновить каждую. + for table in CHECK_TABLES: + op.drop_constraint("ck_channel", table, type_="check") + for table in CHECK_TABLES: + op.create_check_constraint("ck_channel", table, CHANNEL_WITH_PUSH) + + op.create_table( + "app_settings", + sa.Column("key", sa.String(64), primary_key=True), + sa.Column("value", sa.Text(), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_table( + "push_subscriptions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.String(64), nullable=False), + sa.Column("endpoint", sa.String(500), nullable=False, unique=True), + sa.Column("p256dh", sa.String(255), nullable=False), + sa.Column("auth", sa.String(255), nullable=False), + sa.Column("ua", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_pushsubs_user", "push_subscriptions", ["user_id", "created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_pushsubs_user", table_name="push_subscriptions") + op.drop_table("push_subscriptions") + op.drop_table("app_settings") + for table in CHECK_TABLES: + op.drop_constraint("ck_channel", table, type_="check") + for table in CHECK_TABLES: + op.create_check_constraint("ck_channel", table, CHANNEL_NO_PUSH) \ No newline at end of file diff --git a/app/api/admin_routes.py b/app/api/admin_routes.py index 5d36468..590c2c9 100644 --- a/app/api/admin_routes.py +++ b/app/api/admin_routes.py @@ -15,6 +15,7 @@ from app.auth.apikeys import generate_token, token_hash_of from app.auth.deps import AuthenticatedUser, require_admin +from app.config import get_settings from app.database import get_db from app.models import ( ApiKey, @@ -37,6 +38,8 @@ RuleIn, RuleOut, RulePatch, + SettingOut, + SettingsPut, SourceIn, SourceOut, TargetIn, @@ -45,6 +48,8 @@ TypeIn, TypeOut, ) +from app.settings_registry import EDITABLE, is_secret, validate as validate_setting +from app.settings_store import default_of, get_setting_row, set_setting router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) @@ -361,10 +366,10 @@ target = db.get(ChannelTarget, act.target_id) if act.target_id else "ok" if not target: raise HTTPException(status_code=422, detail=f"Цель id={act.target_id} не найдена") - if act.channel == "user" and act.target_id: + if act.channel in ("user", "push") and act.target_id: raise HTTPException( status_code=422, - detail="Канал user адресует конкретного пользователя через payload.user_id — цель не задаётся", + detail=f"Канал {act.channel} адресует пользователя через payload.user_id — цель не задаётся", ) data = payload.model_dump() actions = data.pop("actions") @@ -395,10 +400,10 @@ target = db.get(ChannelTarget, act.get("target_id")) if act.get("target_id") else "ok" if not target: raise HTTPException(status_code=422, detail=f"Цель id={act['target_id']} не найдена") - if act.get("channel") == "user" and act.get("target_id"): + if act.get("channel") in ("user", "push") and act.get("target_id"): raise HTTPException( status_code=422, - detail="Канал user адресует конкретного пользователя через payload.user_id — цель не задаётся", + detail=f"Канал {act['channel']} адресует пользователя через payload.user_id — цель не задаётся", ) db.execute(delete(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule.id)) for act in actions: @@ -524,4 +529,68 @@ created_at=d.created_at, delivered_at=d.delivered_at, ) for d in rows - ] \ No newline at end of file + ] + +# --- Настройки (оверрайды .env; секреты write-only, docs/06) --- + +@router.get("/settings", response_model=list[SettingOut]) +def list_settings( + user: AuthenticatedUser = Depends(require_admin), db: Session = Depends(get_db) +): + """Реестр редактируемых настроек: оверрайд из БД, дефолт из .env, маска секретов.""" + out: list[SettingOut] = [] + env_settings = get_settings() + for defn in EDITABLE.values(): + raw = get_setting_row(db, defn.key) + default = getattr(env_settings, defn.key, None) + out.append( + SettingOut( + key=defn.key, + section=defn.section, + label=defn.label, + kind=defn.kind, + help=defn.help, + options=list(defn.options), + # secret — write-only: факт наличия в set, значение не возвращается + value=None if (is_secret(defn) or raw is None or default is None) else raw, + set=raw is not None or default is not None, + ) + ) + return out + + +@router.put("/settings") +def put_settings( + body: SettingsPut, + user: AuthenticatedUser = Depends(require_admin), + db: Session = Depends(get_db), +): + """Оверрайды поверх .env: строка — установить, null — вернуть дефолт .env. + + Секрет — write-only: "" значит «не менять» (UI не отправляет пустое), + null — очистить оверрайд. Значение хранится строкой; коэрсия — в + settings_store.get_setting (int/bool по реестру). + """ + unknown = [k for k in body.values if k not in EDITABLE] + if unknown: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + f"Неизвестные ключи настроек: {', '.join(sorted(unknown))}", + ) + updated: list[str] = [] + for key, value in body.values.items(): + defn = EDITABLE[key] + if value is None: + set_setting(db, key, None) + elif is_secret(defn): + if value == "": + continue # пустая строка = не менять + set_setting(db, key, value) + else: + err = validate_setting(defn, value) + if err is not None: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, err) + set_setting(db, key, value) + updated.append(key) + db.commit() + return {"ok": True, "updated": updated} diff --git a/app/api/admin_schemas.py b/app/api/admin_schemas.py index ad779be..021d4b1 100644 --- a/app/api/admin_schemas.py +++ b/app/api/admin_schemas.py @@ -88,7 +88,7 @@ # --- rules --- class RuleActionIn(BaseModel): - channel: str = Field(pattern=r"^(telegram|email|s2s|internal_log|user)$") + channel: str = Field(pattern=r"^(telegram|email|s2s|internal_log|user|push)$") target_id: int | None = None template: str | None = None @@ -156,4 +156,22 @@ dedup_key: str | None created_at: datetime expires_at: datetime | None - deliveries: list[DeliveryOut] \ No newline at end of file + deliveries: list[DeliveryOut] + +# --- настройки (оверрайды .env; секреты write-only, docs/06) --- + +class SettingOut(BaseModel): + key: str + section: str + label: str + kind: str # "str" | "int" | "bool" | "secret" + help: str = "" + options: list[str] = [] + # фактическое значение; для secret — всегда null (write-only) + value: str | None = None + # задан ли ключ (override из БД или дефолт .env) + set: bool + + +class SettingsPut(BaseModel): + values: dict[str, str | None] diff --git a/app/api/events_routes.py b/app/api/events_routes.py index 476b9b5..0711e7e 100644 --- a/app/api/events_routes.py +++ b/app/api/events_routes.py @@ -21,9 +21,9 @@ EventStatus, ) from app.auth.apikeys import resolve_event_source -from app.config import get_settings from app.database import get_db from app.models import ApiKey, ChannelTarget, Delivery, Event, NotificationType +from app.settings_store import get_setting from app.worker.celery_app import celery_app router = APIRouter(prefix="/api/v1/events", tags=["ingestion"]) @@ -71,7 +71,7 @@ # Точность в рантайме под нагрузкой не гарантирована (нет unique-констрейнта); # race на «одновременно влетевшие» дублей в MVP не ловим. if envelope.dedup_key: - window = get_settings().dedup_window_seconds + window = int(get_setting(db, "dedup_window_seconds")) prior = db.execute( select(Event) .where( diff --git a/app/api/me_routes.py b/app/api/me_routes.py index 02d42f9..dbcd6d2 100644 --- a/app/api/me_routes.py +++ b/app/api/me_routes.py @@ -1,9 +1,8 @@ """Личный кабинет любого аутентифицированного пользователя gnexus-auth. Роли user (не admin) в админ-часть не пускаются (require_admin), но личное -им доступно: /me — профиль; /me/events — события, которые правила -маршрутизации отправили в канал user (личный лог) с адресатом этим -пользователем (payload.user_id == sub, конвенция docs/05-ingestion-api.md). +им доступно: /me — профиль; /me/events — личный лог (канал user); +/me/push* — web-push подписки (channel push, docs/06-settings-pwa.md). Без ролевого гейта: get_current_user — только аутентификация call-home. """ @@ -11,9 +10,9 @@ import uuid from datetime import datetime -from fastapi import Depends, Query +from fastapi import Depends, HTTPException, Query, Response, status from fastapi import APIRouter -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session @@ -21,7 +20,8 @@ from app.config import get_settings from app.database import get_db from app.api.routes import AdminMe -from app.models import Delivery, Event, Source +from app.models import Delivery, Event, PushSubscription, Source +from app.settings_store import get_setting_row router = APIRouter(prefix="/api/v1", tags=["me"]) @@ -38,6 +38,27 @@ rendered_message: str | None +class MePushOut(BaseModel): + """VAPID для подписки браузера; configured=False — пуш ещё не настроен.""" + + configured: bool + public_key: str | None + + +class PushSubscriptionOut(BaseModel): + id: int + # endpoint целиком — не секрет; UI укорачивает для показа сам + endpoint: str + ua: str | None + created_at: datetime + + +class PushSubscriptionIn(BaseModel): + endpoint: str = Field(min_length=10, max_length=500) + keys: dict # {"p256dh": ..., "auth": ...} + ua: str | None = Field(None, max_length=255) + + @router.get("/me", response_model=AdminMe) def me(user=Depends(get_current_user)) -> AdminMe: """Профиль по токену gnexus-auth (любая роль). SPA-гейт решает по system_role.""" @@ -102,4 +123,81 @@ ) if len(out) >= limit: break - return out \ No newline at end of file + return out + +# --- Web-push подписки (канал push; самопользовательская привязка, docs/06) --- + +@router.get("/me/push", response_model=MePushOut) +def push_config(user=Depends(get_current_user), db: Session = Depends(get_db)): + """Public-ключ VAPID для pushManager.subscribe браузера клиента.""" + public = get_setting_row(db, "vapid_public_key") + return MePushOut(configured=bool(public), public_key=public) + + +@router.get("/me/push/subscriptions", response_model=list[PushSubscriptionOut]) +def my_subscriptions( + user=Depends(get_current_user), db: Session = Depends(get_db) +) -> list[PushSubscriptionOut]: + """Подписки текущего пользователя (его браузерные устройства).""" + sub = str(user.user_id) + rows = ( + db.execute( + select(PushSubscription) + .where(PushSubscription.user_id == sub) + .order_by(PushSubscription.created_at.desc()) + ) + .scalars() + .all() + ) + return [PushSubscriptionOut(id=r.id, endpoint=r.endpoint, ua=r.ua, created_at=r.created_at) for r in rows] + + +@router.post("/me/push/subscriptions", response_model=PushSubscriptionOut, status_code=201) +def save_subscription( + body: PushSubscriptionIn, + user=Depends(get_current_user), + db: Session = Depends(get_db), +): + """Регистрация/обновление подписки этого браузера (upsert по endpoint).""" + keys = body.keys or {} + p256dh = str(keys.get("p256dh") or "").strip() + auth = str(keys.get("auth") or "").strip() + if not p256dh or not auth: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "В подписке нет ключей keys.p256dh/keys.auth от pushManager", + ) + sub = ( + db.execute( + select(PushSubscription).where(PushSubscription.endpoint == body.endpoint) + ) + .scalars() + .first() + ) + if sub is None: + sub = PushSubscription(endpoint=body.endpoint, p256dh=p256dh, auth=auth, ua=body.ua, + user_id=str(user.user_id)) + db.add(sub) + else: + # endpoint уникален: могла остаться под прошлым владельцем (браузер тот же, + # а пользователь в нём другой) — теперь подписка принадлежит этому юзеру. + sub.user_id = str(user.user_id) + sub.p256dh, sub.auth, sub.ua = p256dh, auth, body.ua + db.commit() + db.refresh(sub) + return PushSubscriptionOut(id=sub.id, endpoint=sub.endpoint, ua=sub.ua, created_at=sub.created_at) + + +@router.delete("/me/push/subscriptions/{sub_id}", status_code=204) +def remove_subscription( + sub_id: int, + user=Depends(get_current_user), + db: Session = Depends(get_db), +): + """Отписать свой (user_id совпадает) endpoint; чужой — 404.""" + sub = db.get(PushSubscription, sub_id) + if sub is None or sub.user_id != str(user.user_id): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Подписка не найдена") + db.delete(sub) + db.commit() + return Response(status_code=204) diff --git a/app/models/__init__.py b/app/models/__init__.py index 5e74c6f..a53b28d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.dicts import ApiKey, ChannelTarget, NotificationType, Source from app.models.events import Delivery, Event from app.models.rules import RoutingRule, RoutingRuleAction +from app.models.settings import AppSetting, PushSubscription __all__ = [ "Source", @@ -13,4 +14,6 @@ "RoutingRuleAction", "Event", "Delivery", + "AppSetting", + "PushSubscription", ] diff --git a/app/models/dicts.py b/app/models/dicts.py index ac623eb..2b39a9f 100644 --- a/app/models/dicts.py +++ b/app/models/dicts.py @@ -24,8 +24,8 @@ # Каналы доставки — те же значения в channel_targets и routing_rule_actions. # internal_log не имеет внешних кредов (это БД); user — личный лог пользователя -# (адресат из payload.user_id, цель не задаётся); push (web-push) пока не включаем. -CHANNELS = ("telegram", "email", "s2s", "internal_log", "user") +# и push — web-push подписки пользователя (оба из payload.user_id, цель не задаётся). +CHANNELS = ("telegram", "email", "s2s", "internal_log", "user", "push") PRIORITIES = ("low", "normal", "high", "critical") diff --git a/app/models/events.py b/app/models/events.py index c33b5ce..8ef1933 100644 --- a/app/models/events.py +++ b/app/models/events.py @@ -71,7 +71,7 @@ __tablename__ = "deliveries" __table_args__ = ( CheckConstraint( - "channel IN ('telegram','email','s2s','internal_log','user')", name="ck_channel" + "channel IN ('telegram','email','s2s','internal_log','user','push')", name="ck_channel" ), CheckConstraint( "status IN ('pending','delivered','failed','skipped')", name="ck_delivery_status" diff --git a/app/models/settings.py b/app/models/settings.py new file mode 100644 index 0000000..a7fccdd --- /dev/null +++ b/app/models/settings.py @@ -0,0 +1,49 @@ +"""Хранение настроек и push-подписок: app_settings (оверрайды .env) и +push_subscriptions (личные web-push подписки, см. docs/06). +""" + +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class AppSetting(Base): + """Оверрайд настройки из .env: {key: value} поверх registry-дефолтов.""" + + __tablename__ = "app_settings" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + # Значение хранится строкой (коэрсия по типу в settings_registry); + # для секретов — в открытом виде, но write-only через API. + value: Mapped[str | None] = mapped_column(Text) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class PushSubscription(Base): + """Web-push подписка одного пользователя (одна строка на браузерное устройство). + + Пользователь подписывается сам (личный раздел → «Настройки»); канал push + маршрутизации доставляет события тем, кто подписался на адресата события + (payload.user_id). Отключение = удаление строки + unsubscribe на клиенте. + """ + + __tablename__ = "push_subscriptions" + __table_args__ = ( + Index("ix_pushsubs_user", "user_id", "created_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + # sub gnexus-auth — тот же адресат, что у канала user. + user_id: Mapped[str] = mapped_column(String(64)) + endpoint: Mapped[str] = mapped_column(String(500), unique=True) + p256dh: Mapped[str] = mapped_column(String(255)) + auth: Mapped[str] = mapped_column(String(255)) + ua: Mapped[str | None] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) \ No newline at end of file diff --git a/app/settings_registry.py b/app/settings_registry.py new file mode 100644 index 0000000..80d6b43 --- /dev/null +++ b/app/settings_registry.py @@ -0,0 +1,88 @@ +"""Реестр настраиваемых через UI параметров Synapse. + +.env задаёт дефолты, admin UI перебивает их оверрайдами в таблице +app_settings (app/settings_store.py). Значения секретов хранятся в БД +write-only: никогда не возвращаются через API. Каналы Telegram/SMTP пока +заглушки (#29) — конфиги сохраняем заранее. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SettingDef: + key: str + section: str # routing | push | telegram | smtp + label: str + kind: str # "str" | "int" | "bool" | "secret" + options: tuple[str, ...] = () # для enum — допустимые значения + help: str = "" + + +EDITABLE: dict[str, SettingDef] = {d.key: d for d in ( + SettingDef( + "routing_match_mode", "routing", "Режим маршрутизации", "str", + options=("all", "first"), + help="all — все подошедшие правила; first — только первое по weight", + ), + SettingDef( + "dedup_window_seconds", "routing", "Окно дедупликации, сек", "int", + help="Дубль с тем же dedup_key внутри окна вернёт id первого события", + ), + SettingDef( + "retention_days", "routing", "Ретеншн, дней", "int", + help="События старше N дней удаляются; 0 — отключить окно", + ), + SettingDef( + "vapid_public_key", "push", "VAPID public key", "str", + help="URLsafe base64 публичный ключ (p256dh)", + ), + SettingDef( + "vapid_private_key", "push", "VAPID private key", "secret", + help="Секрет хранится в БД write-only, в API возвращается только факт «задано»", + ), + SettingDef( + "push_subject", "push", "Subject (отправитель)", "str", + help="Контакты отправителя для веб-пушей, например mailto:ops@gnexus.space", + ), + SettingDef( + "telegram_bot_token", "telegram", "Токен тг-бота", "secret", + help="Канал ещё не реализован (#29) — токен сохраняется впрок", + ), + SettingDef("smtp_host", "smtp", "SMTP host", "str"), + SettingDef("smtp_port", "smtp", "SMTP port", "int"), + SettingDef("smtp_user", "smtp", "SMTP user", "str"), + SettingDef("smtp_password", "smtp", "SMTP password", "secret"), + SettingDef( + "smtp_from", "smtp", "From-адрес", "str", + help='Например "Synapse "', + ), +)} + +SECRET_KINDS = ("secret",) + +INT_RANGES = { + "dedup_window_seconds": (1, 10**6), + "retention_days": (0, 366), + "smtp_port": (1, 65535), +} + + +def is_secret(defn: SettingDef) -> bool: + return defn.kind in SECRET_KINDS + + +def validate(defn: SettingDef, raw: str) -> str | None: + """Валидация значения из UI; None — ок, иначе текст ошибки.""" + if defn.kind == "int": + # диапазоны все неотрицательные — минус не нужен; "" сразу отвергаем + if not raw.isdigit(): + return f"{defn.key}: значение должно быть числом" + low, high = INT_RANGES.get(defn.key, (0, 10**9)) + if not low <= int(raw) <= high: + return f"{defn.key}: значение вне допустимого диапазона {low}..{high}" + if defn.kind == "bool" and raw not in ("true", "false"): + return f"{defn.key}: значение должно быть true/false" + if defn.options and raw not in defn.options: + return f"{defn.key}: допустимо {' или '.join(defn.options)}" + return None \ No newline at end of file diff --git a/app/settings_store.py b/app/settings_store.py new file mode 100644 index 0000000..16122eb --- /dev/null +++ b/app/settings_store.py @@ -0,0 +1,48 @@ +"""Слой поверх .env: эффективная настройка = env-дефолт + оверрайд из app_settings. + +Без кэша — чтения редкие (на приём/ingest/expire), один SELECT по PK, а свежесть +для владельца важнее; Redis-инвалидацию добавим, если появится нагрузка. +""" + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models import AppSetting +from app.settings_registry import EDITABLE, SettingDef + + +def default_of(defn: SettingDef) -> str | None: + """Дефолт из .env; None — ключа в Settings нет (его задают только через UI).""" + return getattr(get_settings(), defn.key, None) + + +def get_setting_row(db: Session, key: str) -> str | None: + """Необработанное значение из БД (None — оверрайда нет).""" + row = db.get(AppSetting, key) + return row.value if row is not None else None + + +def get_setting(db: Session, key: str) -> str | bool: + """Эффективное значение, коэрсия по типу реестра: строка или bool.""" + defn = EDITABLE[key] + raw = get_setting_row(db, key) + raw = default_of(defn) if raw is None else raw + if defn.kind == "int": + return int(raw) if raw is not None else 0 + if defn.kind == "bool": + return raw in ("true", "1") + return raw or "" + + +def set_setting(db: Session, key: str, value: str | None) -> None: + """Upsert; value=None — удалить оверрайд (вернётся дефолт .env).""" + if value is None: + row = db.get(AppSetting, key) + if row is not None: + db.delete(row) + return + row = db.get(AppSetting, key) + if row is None: + db.add(AppSetting(key=key, value=value)) + else: + row.value = value \ No newline at end of file diff --git a/app/worker/senders.py b/app/worker/senders.py index 2ea3888..5b51064 100644 --- a/app/worker/senders.py +++ b/app/worker/senders.py @@ -1,8 +1,9 @@ -"""Отправка в системные webhooks (s2s): подпись app/signature.py, docs/05. +"""Отправки: s2s webhooks (подпись app/signature.py, docs/05) и +web-push подпискам пользователя (канал push, docs/06). -Секрет цели — per-target: channel_targets.config = {"endpoint": …, "token_ref": -"navi-rei"}; значение секрета — в окружении (S2S_SECRET_NAVI_REI, .env / -gnexus-creds), в БД только ссылка. Ротация секрета цели не трогает правила. +Секреты: s2s цели — per-target из env (S2S_SECRET_); VAPID — из +app_settings (Настройки → Web Push, write-only). Секреты никогда не +возвращаются через API — воркер читает их значения напрямую. """ import json @@ -10,8 +11,11 @@ import time import httpx +from pywebpush import WebPushException, webpush as pywebpush +from sqlalchemy import select -from app.models import ChannelTarget, Event +from app.models import ChannelTarget, Delivery, Event, PushSubscription +from app.settings_store import get_setting_row from app.signature import make_signature #: Референс-получателю достаточно этих трёх; остальное — настройка цели. @@ -79,4 +83,65 @@ if 200 <= response.status_code < 300: return True, None - return False, f"s2s: HTTP {response.status_code} · {response.text[:200]}" \ No newline at end of file + return False, f"s2s: HTTP {response.status_code} · {response.text[:200]}" + + +def send_push( + db, delivery: Delivery, event: Event, source_name: str +) -> tuple[bool, str | None]: + """Web-push подпискам получателя (delivery.recipient_user_id, docs/06). + + VAPID — из настроек (app_settings, «Настройки → Web Push»); секретный ключ + write-only, воркеру достаточно факта его наличия. Подписки со статусами + 404/410 (браузер отписался/истёк) удаляются; успех — все живые подписки + отправлены. Подписок нет — ошибка «нет активных подписок»: ретраи не + спасут, но аудит объясняет, куда событие не ушло. + """ + public = get_setting_row(db, "vapid_public_key") + private = get_setting_row(db, "vapid_private_key") + subject = get_setting_row(db, "push_subject") or "mailto:synapse@gnexus.space" + if not public or not private: + return False, "push: VAPID-ключи не заданы — Настройки → Web Push" + + subscriptions = db.execute( + select(PushSubscription).where( + PushSubscription.user_id == delivery.recipient_user_id + ) + ).scalars().all() + if not subscriptions: + return False, "push: у пользователя нет активных подписок" + + payload = json.dumps( + { + "title": f"Synapse · {source_name}.{event.subject}.{event.action}", + "body": delivery.rendered_message or f"{source_name}.{event.subject}.{event.action}", + "event_id": str(event.id), + "url": "/my-events", + }, + ensure_ascii=False, + ) + gone, failed = [], [] + for sub in subscriptions: + try: + pywebpush( + subscription_info={ + "endpoint": sub.endpoint, + "keys": {"p256dh": sub.p256dh, "auth": sub.auth}, + }, + data=payload, + vapid_private_key=private, + vapid_claims={"sub": subject}, + ) + except WebPushException as err: + code = getattr(getattr(err, "response", None), "status_code", None) + if code in (404, 410): + gone.append(sub) # браузер отписался/истёк — чистим + continue + failed.append(f"HTTP {code or '?'}") + except Exception as err: # сеть и прочее — попробуем в ретрае + failed.append(str(err)[:120]) + for sub in gone: + db.delete(sub) + if failed: + return False, "; ".join(failed) + return True, None \ No newline at end of file diff --git a/app/worker/tasks.py b/app/worker/tasks.py index 9678d32..75709ac 100644 --- a/app/worker/tasks.py +++ b/app/worker/tasks.py @@ -20,7 +20,6 @@ from jinja2 import Template from sqlalchemy import delete, select -from app.config import get_settings from app.database import SessionLocal from app.models import ( ChannelTarget, @@ -31,7 +30,8 @@ Source, ) from app.worker.celery_app import celery_app -from app.worker.senders import send_s2s +from app.settings_store import get_setting +from app.worker.senders import send_s2s, send_push PRIORITY_ORDER = {"low": 0, "normal": 1, "high": 2, "critical": 3} @@ -41,6 +41,9 @@ # Каналы, чью отправку воркер ещё не умеет (#29). _CHANNELS_NOT_IMPL = ("telegram", "email") +# Целевые каналы без цели: адресат — payload.user_id (docs/05, docs/06). +_USER_CHANNELS = ("user", "push") + def _attempt_send( db, delivery: Delivery, event: Event, source_name: str @@ -59,6 +62,9 @@ if target is None: return False, "s2s: у доставки нет цели (target_id пуст)" return send_s2s(target, event, source_name) + if delivery.channel == "push": + # Web-push: подписки получателя хранятся в push_subscriptions (docs/06). + return send_push(db, delivery, event, source_name) if delivery.channel in _CHANNELS_NOT_IMPL: return False, "канал ещё не реализован (#29)" return False, f"неизвестный канал {delivery.channel}" @@ -210,7 +216,8 @@ payload=dict(event.payload or {}), ) ] - if get_settings().routing_match_mode == "first": + # Оверрайд админки может резолвить и первый режим — читаем каждый раз. + if get_setting(db, "routing_match_mode") == "first": matched = matched[:1] now = datetime.now(UTC) @@ -220,10 +227,10 @@ select(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule.id) ).scalars().all() for act in actions: - # Канал user адресует пользователя из payload.user_id + # Каналы user/push адресуют пользователя из payload.user_id # (конвенция, docs/05); без валидного sub доставить некому. recipient, user_reason = None, None - if act.channel == "user": + if act.channel in _USER_CHANNELS: recipient, user_reason = _user_recipient(event) # шумодав: срезаем (с аудит-записью), critical проходит @@ -267,8 +274,10 @@ ) continue - if act.channel == "user": - # Личный лог пользователя: также исполняется сразу. + if act.channel in _USER_CHANNELS: + # Лично-адресованные каналы: user — личный лог (исполняется + # сразу), push — отправка в подписки пользователя (через + # _attempt_send/ретраи). Без адресата — skipped с аудитом. if recipient is None: # Аудит: правило направило, но адресовать некому — # админ увидит причину в /admin/deliveries. @@ -287,20 +296,47 @@ ) n_skipped += 1 continue - db.add( - Delivery( - event_id=event.id, - rule_id=rule.id, - channel=act.channel, - recipient_user_id=recipient, - status="delivered", - attempts=1, - rendered_message=_render( - act.template or rule.template or "", event, source.name - ), - delivered_at=now, - ) + + message = _render( + act.template or rule.template or "", event, source.name ) + if act.channel == "user": + db.add( + Delivery( + event_id=event.id, + rule_id=rule.id, + channel=act.channel, + recipient_user_id=recipient, + status="delivered", + attempts=1, + rendered_message=message, + delivered_at=now, + ) + ) + continue + + # push: pending-доставка с готовым сообщением; отправка + # во все подписки получателя — в _attempt_send. + delivery = Delivery( + event_id=event.id, + rule_id=rule.id, + channel=act.channel, + recipient_user_id=recipient, + status="pending", + attempts=0, + rendered_message=message, + ) + db.add(delivery) + db.flush() + ok, error = _attempt_send(db, delivery, event, source.name) + if ok: + delivery.status = "delivered" + delivery.delivered_at = now + delivery.last_error = None + else: + delivery.last_error = error + delivery.next_retry_at = now + timedelta(seconds=RETRY_DELAYS[0]) + delivery.attempts = 1 continue # Фактическая отправка (internal_log уже ушёл выше): первая @@ -392,15 +428,14 @@ числе записи личного лога канала user (/api/v1/me/events читает их). RETENTION_DAYS <= 0 отключает удаление по возрасту (ttl продолжит). """ - settings = get_settings() now = datetime.now(UTC) ids: list[uuid.UUID] = [] with SessionLocal() as db: ids += db.execute( select(Event.id).where(Event.expires_at.is_not(None), Event.expires_at < now) ).scalars().all() - if settings.retention_days > 0: - cutoff = now - timedelta(days=settings.retention_days) + if get_setting(db, "retention_days") > 0: + cutoff = now - timedelta(days=get_setting(db, "retention_days")) ids += db.execute( select(Event.id).where(Event.created_at < cutoff).limit(5000) ).scalars().all() diff --git a/docs/05-ingestion-api.md b/docs/05-ingestion-api.md index 8aacf06..a668131 100644 --- a/docs/05-ingestion-api.md +++ b/docs/05-ingestion-api.md @@ -46,7 +46,7 @@ ### Конвенция `payload.user_id` (соглашение 2026-10-03) -Если событие связано с конкретным пользователем, источник кладёт его id gnexus-auth (`sub`) в `payload.user_id` — это часть «что случилось», а не получатель: конверт по-прежнему ничего не знает о получателях. Что Synapse делает с этой привязкой — решают правила: событие попадает в **личный лог пользователя** (`/api/v1/me/events`) только если подошло правило маршрутизации с действием канала `user` (цель не задаётся); правила без такого действия — «событие только для админа». Позже тот же механизм направит push-каналы (#29) конкретному пользователю. +Если событие связано с конкретным пользователем, источник кладёт его id gnexus-auth (`sub`) в `payload.user_id` — это часть «что случилось», а не получатель: конверт по-прежнему ничего не знает о получателях. Что Synapse делает с этой привязкой — решают правила: событие попадает в **личный лог пользователя** (`/api/v1/me/events`) только если подошло правило маршрутизации с действием канала `user` (цель не задаётся); правила без такого действия — «событие только для админа». Канал `push` (web-push, docs/06) работает по той же семантике — доставляется в подписки пользователя из `payload.user_id` (см. так же открытый вопрос №3 в CLAUDE.md: привязка tg chat_id/email остаётся открытой). Формат значения: непустая строка (число тоже примем — приведётся к строке). Всё прочее (массив, объект, bool) — при маршрутизации записывается как `skipped` с причиной в `/admin/deliveries`: доставка «в никуда» обязана быть видимой, не тихой. Внутри s2s-тела payload уходит как есть — получатель-сервис получает `payload.user_id` без специальных полей. @@ -101,7 +101,7 @@ Действия — по одному на строку: канал + цель (`telegram:infra-alerts`, `internal_log`, позднее `email`, `s2s:navi-rei`) + опциональный шаблон (Jinja2, `{{ payload.x }}`; шаблон действия переопределяет шаблон правила). Шаблон обязан быть устойчив к отсутствию полей payload. -Каналы без цели: `internal_log` (журнал Synapse) и `user` (личный лог пользователя — адресат из `payload.user_id`; событие без этого поля пишется как `skipped` с причиной, админ видит её в `/admin/deliveries`). Событие попадает пользователю в личный лог (`/api/v1/me/events`) только через правило с действием `user`. +Каналы без цели: `internal_log` (журнал Synapse), `user` (личный лог пользователя — адресат из `payload.user_id`; событие без этого поля пишется как `skipped` с причиной, админ видит её в `/admin/deliveries`) и `push` (web-push в подписки того же адресата; без `payload.user_id` — `skipped`, без VAPID/подписок — `pending` с ошибкой и ретраями). Событие попадает пользователю в личный лог (`/api/v1/me/events`) только через правило с действием `user`. ### Сколько правил применяется diff --git a/docs/06-settings-pwa.md b/docs/06-settings-pwa.md new file mode 100644 index 0000000..13b9852 --- /dev/null +++ b/docs/06-settings-pwa.md @@ -0,0 +1,120 @@ +# 06 · Настройки и PWA (web-push) + +Оба механизма — второй шаг многопользовательской волны (2026-10-03): +- **Настройки** — редактируемые параметры Synapse: дефолт из `.env`, оверрайды через админку (хранятся в БД, применяются без перезапуска контейнера). Секреты — write-only: значение записывается в БД, но никогда не возвращается API. +- **PWA + web-push** — SPA устанавливается как приложение (`manifest.webmanifest` + service worker `sw.js`), канал `push` доставляет уведомления в браузеры пользователя через VAPID web-push. + +--- + +## Настройки: реестр, хранение, API + +### Реестр + +`app/settings_registry.py` — единственное место, где описан редактируемый параметр: + +```python +SettingDef(key, section, label, kind, options=(), help="") +``` + +`kind`: `str | int | bool | secret` (секрет — `password`-поле UI). Сейчас в реестре: + +| section | ключ | kind | назначение | +|---|---|---|---| +| routing | `routing_match_mode` | str(all/first) | режим матчинга правил | +| routing | `dedup_window_seconds` | int 1..10^6 | окно дедупликации | +| routing | `retention_days` | int 0..366 | окно ретеншна (0 — выкл) | +| push | `vapid_public_key` | str | публичный VAPID-ключ | +| push | `vapid_private_key` | **secret** | приватный VAPID-ключ | +| push | `push_subject` | str | контакты отправителя (`mailto:…`) | +| telegram | `telegram_bot_token` | **secret** | токен бота (канал #29 не готов) | +| smtp | `smtp_host` / `smtp_port` / `smtp_user` | str/int | SMTP-конфиг (канал #29 не готов) | +| smtp | `smtp_password` | **secret** | пароль SMTP | +| smtp | `smtp_from` | str | From-адрес | + +Новая настройка = запись в `EDITABLE`; чтения — в `settings_store.get_setting(db, key)`. Ключи реестра, совпадающие с полями `app.config.Settings` (`routing_match_mode`, `dedup_window_seconds`, `retention_days`), получают дефолт из `.env`; ключи, которых в config нет (`vapid_*`, `smtp_*`, `telegram_*`) задаются только через UI. + +### Хранение и резолв + +- Оверрайды — таблица `app_settings(key PK, value, updated_at)`; строка — установить, удалить строку — вернуть дефолт. +- `settings_store.get_setting(db, key)` — env-дефолт + оверрайд, коэрсия по типу реестра (int → `int(raw)`, bool → `true/1`). Без кэша: чтения редкие (приём, ingest, expire-прогон), один SELECT по PK. +- `settings_store.get_setting_row(db, key)` — сырое значение (для секретов в воркере: send_push). +- `set_setting(db, key, None)` — удалить оверрайд. + +### API (под require_admin) + +- `GET /api/v1/admin/settings` → список из реестра: `{key, section, label, kind, help, options, value, set}`. Для секрета `value` всегда `null`, факт наличия дефолта/оверрайда — `set`. +- `PUT /api/v1/admin/settings` body `{"values": {key: "строка" | null}}`: + - неизвестный ключ → `422`; + - int вне диапазона / enum не из options → `422`; + - секрет: `""` — «не менять» (UI не отправляет пустое), непустая строка — set, `null` — сброс; + - не-секрет `null` — вернуть дефолт `.env`. + +Секреты читает только воркер (send_push) — API их не отдаёт. Это осознанное отступление от правила «секретам не жить в БД»: секреты должны быть редактируемы из UI, хранилище БД write-only с ограничением чтения уровнем кода (владелец согласил 2026-10-03). Прочие секреты (gauth client secret и т.п.) — по-прежнему только `.env` + gnexus-creds. + +### UI + +`/settings` — один раздел для всех ролей: у пользователя личный блок web-push, у `admin+` ещё секции конфига (Маршрутизация / Web Push / Telegram / SMTP). Секрет — `password`-поле с плейсхолдером «••••• задано — оставить пустым, чтобы не менять»; кнопка «Перебить дефолт» шлёт только изменённые значения (пустое поле открытой настройки = `null` = сброс оверрайда). + +--- + +## PWA и web-push + +### Оболочка + +- `frontend/public/manifest.webmanifest` — name/short_name Synapse, `start_url: "/"`, `display: standalone`, theme `#16161E`, иконки 192/512 (+512 maskable) из `gnexus-mark.svg`. +- `frontend/public/sw.js` — pass-through fetch (никаких оффлайн-кэшей: API-приложение), обработчики `push` (showNotification, payload JSON `{title, body, event_id, url}`) и `notificationclick` (фокус существующего окна / openWindow на `url`). +- `index.html` — `` + `theme-color`; файлы отдаёт spa_fallback из dist контейнера API. +- Регистрация SW — в `main.js` после mount; ошибки — console.warn, приложение не блокируется. + +### Подписка (самоопределение адресата) + +Решает половину открытого вопроса №3: привязка «user_id ↔ адрес идентичности канала» для push создается самим пользователем — браузер подписывается, Synapse хранит только `(user_id, endpoint, keys)`. + +Каналы API (`app/api/me_routes.py`, любой аутентифицированный): + +- `GET /api/v1/me/push` → `{configured, public_key}` (public из настроек; `configured=false` — VAPID не заданы); +- `GET /api/v1/me/push/subscriptions` — свои устройства; +- `POST /api/v1/me/push/subscriptions` `{endpoint, keys{p256dh,auth}, ua}` — 201, upsert по endpoint (endpoint уникален: подписка «переезжает» новому пользователю, если браузер сменил аккаунт); +- `DELETE /api/v1/me/push/subscriptions/{id}` — 204 (только свой, чужой 404). + +Клиентская логика — `frontend/src/push.js`: разрешение браузера, `pushManager.subscribe` с `applicationServerKey`, база64url → Uint8Array. + +### Канал `push` + +- `CHANNELS += "push"` — в правило ставится действие `{"channel": "push"}` (цель не задаётся — как у `user`). +- Адресат — `payload.user_id`: без валидного sub → `skipped` с причиной в `/admin/deliveries`; без VAPID или без подписок → `pending` с ошибкой и ретраями. +- Воркер (`app/worker/senders.py: send_push`): на каждую подписку `pywebpush(..., vapid_private_key, vapid_claims={sub: push_subject})`; ответ 404/410 (браузер отписался/endpoint истёк) — подписка удаляется; успех — когда все живые подписки отправлены (доставок нет — тоже ошибка аудитора, не тихий пропуск). + +### Как задать VAPID (рецепт) + +Генерация пары P-256-ключей и значения для UI (проверено в контейнере): + +```bash +python3 - <<'PY' +import base64 +from cryptography.hazmat.primitives.serialization import ( + Encoding, NoEncryption, PublicFormat, PrivateFormat, +) +from py_vapid import Vapid + +def b64u(b): + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + +v = Vapid() +v.generate_keys() +print("public :", b64u(v.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint))) +print("private:", b64u(v.private_key.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption()))) +PY +``` + +`public` (base64url без-padding) — в «Настройки → Web Push → VAPID public key», `private` (DER в base64url) — в VAPID private key (write-only); воркер передаёт её через `pywebpush` → `Vapid.from_string` (raw/DER в base64url — сам pywebpush PEM-строки в этом формате не разбирает). `push_subject` — `mailto:you@gnexus.space`. + +> ⚠️ Смена приватного VAPID-ключа инвалидирует все подписки браузеров — после смены пользователи перезакажут подписку тумблером в разделе «Настройки». + +--- + +## Проверено + +- `/tmp/setpush.py` (47 проверок, ALL OK): маска секретов, PUT-валидация, эффективные чтения оверрайдов, `me/push*`, CRUD подписок (upsert/чужой 404/смена владельца endpoint), routing push (skipped/pending), `send_push` (мок: 3 подписки delivered, 410-подписка удаляется). +- Регресс: `/tmp/mecheck.py`, `/tmp/retcheck.py` — ALL OK. +- Puppeteer `/tmp/navcheck/setpush.js` (17 проверок, ALL OK): user — личный блок и отсутствие админ-секций; user при не-настроенном VAPID — карточка «Сервер ещё не настроен»; admin — секции, маска секретов, GnSelect; nav-пункт «Настройки» у обеих ролей. \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index df267d2..09776b1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,11 @@ Synapse Admin + + + + +
diff --git a/frontend/public/assets/imgs/apple-touch-icon-180.png b/frontend/public/assets/imgs/apple-touch-icon-180.png new file mode 100644 index 0000000..74d0883 --- /dev/null +++ b/frontend/public/assets/imgs/apple-touch-icon-180.png Binary files differ diff --git a/frontend/public/assets/imgs/synapse-icon-192.png b/frontend/public/assets/imgs/synapse-icon-192.png new file mode 100644 index 0000000..b850bdd --- /dev/null +++ b/frontend/public/assets/imgs/synapse-icon-192.png Binary files differ diff --git a/frontend/public/assets/imgs/synapse-icon-512.png b/frontend/public/assets/imgs/synapse-icon-512.png new file mode 100644 index 0000000..f84c01d --- /dev/null +++ b/frontend/public/assets/imgs/synapse-icon-512.png Binary files differ diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..03e8d63 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "Gnexus Synapse", + "short_name": "Synapse", + "description": "Центральный хаб уведомлений экосистемы Gnexus", + "lang": "ru", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#16161E", + "theme_color": "#16161E", + "icons": [ + { "src": "/assets/imgs/synapse-icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/assets/imgs/synapse-icon-512.png", "sizes": "512x512", "type": "image/png" }, + { + "src": "/assets/imgs/synapse-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..9feabe2 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,68 @@ +/* Сервис-воркер Synapse: пуш-уведомления и клики по ним. + * + * Специально минималистичный: кэширование статики (offline shell) не делаем — + * приложение гоняется через API и протухший кэш хуже плохого оффлайна; + * fetch — pass-through (Chrome требует обработчик для installable, не перехватываем). + * Захват только push/notificationclick: payload {title, body, event_id, url} (docs/06). + */ + +// При выпуске новой версии SW менять строку — активация вытеснит старую на activate. +const VERSION = "synapse-sw-v1"; + +self.addEventListener("install", () => self.skipWaiting()); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + // подчищаем старые версии воркера + for (const key of await caches.keys()) await caches.delete(key); + await self.clients.claim(); + })(), + ); +}); + +// Chrome требует fetch-обработчик для installable-страниц; ничего не кэшируем. +self.addEventListener("fetch", () => {}); + +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + // текстовый payload не по нашему формату: показываем сырой текст + data = { body: event.data ? event.data.text() : "" }; + } + const payload = { + title: data.title || "Synapse", + body: data.body || "", + url: data.url || "/my-events", + tag: data.event_id || undefined, // дедуп показов одного события + }; + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + tag: payload.tag, + icon: "/assets/imgs/synapse-icon-192.png", + badge: "/assets/imgs/synapse-icon-192.png", + data: { url: payload.url }, + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + const url = (event.notification.data && event.notification.data.url) || "/my-events"; + event.notification.close(); + event.waitUntil( + (async () => { + const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of clients) { + if ("focus" in client) { + await client.focus(); + if (client.navigate) await client.navigate(url); + return; + } + } + await self.clients.openWindow(url); + })(), + ); +}); \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index affcb95..5c803ca 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -30,6 +30,7 @@ { to: "/targets", label: "Цели", icon: "ph-target" }, { to: "/rules", label: "Правила", icon: "ph-funnel" }, { to: "/deliveries", label: "Доставки", icon: "ph-paper-plane-tilt" }, + { to: "/settings", label: "Настройки", icon: "ph-gear-six" }, ]; const adminCurrents = { "/dashboard": "Обзор", @@ -38,10 +39,14 @@ "/targets": "Цели", "/rules": "Правила", "/deliveries": "Доставки", + "/settings": "Настройки", }; const items = computed(() => profile.value?.system_role === "user" - ? [{ to: "/my-events", label: "Мои события", icon: "ph-user" }] + ? [ + { to: "/my-events", label: "Мои события", icon: "ph-user" }, + { to: "/settings", label: "Настройки", icon: "ph-gear-six" }, + ] : adminItems, ); // Префиксно: /sources/7 тоже подписывается «Источники». diff --git a/frontend/src/api.js b/frontend/src/api.js index 3726a5a..e6136e0 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -98,6 +98,8 @@ error.status = response.status; throw error; } + // 204 (удаления и др.) — тела нет, response.json() бы бросил SyntaxError + if (response.status === 204) return null; return response.json(); } @@ -174,8 +176,18 @@ listEvents: (query = {}) => apiFetch(`/api/v1/admin/events?${new URLSearchParams(query)}`), listDeliveries: (query = {}) => apiFetch(`/api/v1/admin/deliveries?${new URLSearchParams(query)}`), + // Настройки (оверрайды .env; секреты write-only) + adminSettings: () => apiFetch("/api/v1/admin/settings"), + saveAdminSettings: (values) => + apiFetch("/api/v1/admin/settings", { method: "PUT", body: JSON.stringify({ values }) }), + // Личный кабинет myEvents: (query = {}) => apiFetch(`/api/v1/me/events?${new URLSearchParams(query)}`), + pushConfig: () => apiFetch("/api/v1/me/push"), + listSubscriptions: () => apiFetch("/api/v1/me/push/subscriptions"), + saveSubscription: (data) => + apiFetch("/api/v1/me/push/subscriptions", { method: "POST", body: JSON.stringify(data) }), + removeSubscription: (id) => apiFetch(`/api/v1/me/push/subscriptions/${id}`, { method: "DELETE" }), }; /** Логаут: revoke всех входов пользователя (наш + на gnexus-auth). */ diff --git a/frontend/src/main.js b/frontend/src/main.js index b54dc81..c55293e 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -9,4 +9,13 @@ // Ждём первичной навигации: иначе первый рендер идёт со START-локацией ("/"), // и каркас (и heartbeat в App.vue) срабатывает ещё до того, как роутер узнал // реальный путь. isReady -> mount решает это. -router.isReady().then(() => app.mount("#app")); \ No newline at end of file +router.isReady().then(() => { + app.mount("#app"); + // PWA: пуш-воркер (sw.js регистрируется на корне — scope "/"); ошибки не + // блокируют приложение (пуш-недоступен, раздел «Настройки» покажет статус). + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sw.js").catch((err) => { + console.warn("SW не зарегистрирован:", err); + }); + } +}); \ No newline at end of file diff --git a/frontend/src/push.js b/frontend/src/push.js new file mode 100644 index 0000000..f6bbbca --- /dev/null +++ b/frontend/src/push.js @@ -0,0 +1,75 @@ +/** Web-push на клиенте: разрешение браузера, подписка/отписка. + * Работает парой со sw.js (пуш/клик) и API /me/push* (docs/06-settings-pwa.md). */ + +/** base64url (public key VAPID) → bytes для pushManager */ +function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(base64); + const output = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) output[i] = raw.charCodeAt(i); + return output; +} + +/** Пуш возможен: SW/https и API браузера. Не поддерживается — UI покажет почему. */ +export function pushSupported() { + return Boolean( + typeof window !== "undefined" && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window, + ); +} + +export function swRegistration() { + return navigator.serviceWorker.getRegistration("/"); +} + +/** Текущее разрешение браузера: "granted"|"denied"|"default"|"unsupported" */ +export function permissionState() { + if (!("Notification" in window)) return "unsupported"; + return Notification.permission; +} + +/** Активная подписка текущего браузера (null — нет). */ +export async function currentSubscription() { + const reg = await swRegistration(); + if (!reg) return null; + return reg.pushManager.getSubscription(); +} + +/** + * Договориться с пользователем о разрешении и подписать браузер. + * Возвращает объект для POST /me/push/subscriptions. + * Отклонённое ("denied") разрешение не повторяем — возвращает null. + */ +export async function subscribe(vapidPublicKey) { + if (!pushSupported()) throw new Error("Браузер не поддерживает web-push"); + if (Notification.permission === "denied") return null; + if (Notification.permission !== "granted") { + const outcome = await Notification.requestPermission(); + if (outcome !== "granted") return null; + } + const reg = await swRegistration(); + if (!reg) throw new Error("Service worker ещё не готов (обновите страницу)"); + const existing = await reg.pushManager.getSubscription(); + const subscription = + existing || + (await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + })); + const data = subscription.toJSON(); + return { + endpoint: data.endpoint, + keys: data.keys, + ua: navigator.userAgent, + }; +} + +/** Отписать браузер от push (сервер — через removeSubscription). */ +export async function unsubscribe() { + const reg = await swRegistration(); + const subscription = reg && (await reg.pushManager.getSubscription()); + if (subscription) await subscription.unsubscribe(); +} \ No newline at end of file diff --git a/frontend/src/router.js b/frontend/src/router.js index 92818ce..04e8776 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -13,6 +13,7 @@ import RulesView from "./views/RulesView.vue"; import RuleDetailView from "./views/RuleDetailView.vue"; import DeliveriesView from "./views/DeliveriesView.vue"; +import SettingsView from "./views/SettingsView.vue"; // Гейт ролей. Сервер отсекает админ-API (require_admin -> 403); фронт заранее // разводит путь: роль из GET /api/v1/me (см. getSession в api.js). Админ — @@ -28,7 +29,9 @@ { path: "/denied", component: DeniedView, meta: { bare: true } }, { path: "/oauth/callback", component: OauthCallbackView, meta: { bare: true } }, // Личный раздел — любая роль; админские пути — requiresAdmin (гейт ниже). + // /settings — один раздел для всех: личные пуш-настройки, админу ещё конфиги. { path: "/my-events", component: MyEventsView, meta: { userOk: true } }, + { path: "/settings", component: SettingsView, meta: { userOk: true } }, { path: "/dashboard", component: DashboardView, diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..1edf304 --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,353 @@ + + + + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 140bde0..31750f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ psycopg[binary]>=3.1 httpx>=0.27 gnexus-gauth @ git+https://git.gnexus.space/git/root/gnexus-auth-client-py.git +pywebpush>=2.0 jinja2>=3.1