mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-06 21:42:52 +02:00
Add web push
This commit is contained in:
@@ -50,6 +50,10 @@ app/
|
||||
├── events.py # Typed WS event payload serialization
|
||||
├── websocket.py # WS manager + broadcast helpers
|
||||
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
|
||||
├── push/ # Web Push notification subsystem
|
||||
│ ├── vapid.py # VAPID key generation, storage, caching
|
||||
│ ├── send.py # pywebpush wrapper (async via thread executor)
|
||||
│ └── manager.py # Push dispatch: filter, build payload, concurrent send
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
@@ -71,6 +75,7 @@ app/
|
||||
├── fanout.py
|
||||
├── repeaters.py
|
||||
├── statistics.py
|
||||
├── push.py
|
||||
└── ws.py
|
||||
```
|
||||
|
||||
@@ -168,6 +173,17 @@ app/
|
||||
- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes.
|
||||
- See `app/fanout/AGENTS_fanout.md` for full architecture details and event payload shapes.
|
||||
|
||||
### Web Push notifications
|
||||
|
||||
Web Push is a standalone subsystem in `app/push/`, separate from the fanout module system. It sends browser push notifications for incoming messages even when the tab is closed.
|
||||
|
||||
- **Not a fanout module** — Web Push manages per-browser subscriptions (N browsers, each with own endpoint and preferences), unlike fanout which is one-config-to-one-destination.
|
||||
- **VAPID keys**: auto-generated P-256 key pair on first startup, stored in `app_settings.vapid_private_key` / `vapid_public_key`. Cached in-module by `app/push/vapid.py`.
|
||||
- **Dispatch**: `broadcast_event()` in `websocket.py` fires `push_manager.dispatch_message(data)` alongside fanout for `message` events. The manager loads all subscriptions, filters each by its `filter_mode` (`all_messages`, `all_dms`, `selected`), builds a notification payload, and sends concurrently via `pywebpush` (run in thread executor).
|
||||
- **Stale cleanup**: HTTP 404/410 from the push service triggers immediate subscription deletion.
|
||||
- **Subscriptions stored** in `push_subscriptions` table with `UNIQUE(endpoint)` for upsert semantics.
|
||||
- Requires HTTPS (self-signed OK) and outbound internet to reach browser push services.
|
||||
|
||||
## API Surface (all under `/api`)
|
||||
|
||||
### Health
|
||||
@@ -258,6 +274,14 @@ app/
|
||||
### Statistics
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||
|
||||
### Push
|
||||
- `GET /push/vapid-public-key` — VAPID public key for browser `PushManager.subscribe()`
|
||||
- `POST /push/subscribe` — register/upsert push subscription (keyed by endpoint URL)
|
||||
- `GET /push/subscriptions` — list all push subscriptions
|
||||
- `PATCH /push/subscriptions/{id}` — update label or filter preferences
|
||||
- `DELETE /push/subscriptions/{id}` — delete subscription
|
||||
- `POST /push/subscriptions/{id}/test` — send test notification
|
||||
|
||||
### WebSocket
|
||||
- `WS /ws`
|
||||
|
||||
@@ -290,7 +314,8 @@ Main tables:
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters)
|
||||
- `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs)
|
||||
- `app_settings`
|
||||
- `push_subscriptions` (Web Push browser subscriptions with per-conversation filter preferences; UNIQUE on endpoint)
|
||||
- `app_settings` (includes `vapid_private_key` and `vapid_public_key` for Web Push VAPID signing)
|
||||
|
||||
Contact route state is canonicalized on the backend:
|
||||
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`
|
||||
|
||||
10
app/main.py
10
app/main.py
@@ -67,6 +67,7 @@ from app.routers import (
|
||||
health,
|
||||
messages,
|
||||
packets,
|
||||
push,
|
||||
radio,
|
||||
read_state,
|
||||
repeaters,
|
||||
@@ -102,6 +103,14 @@ async def lifespan(app: FastAPI):
|
||||
await db.connect()
|
||||
logger.info("Database connected")
|
||||
|
||||
# Initialize VAPID keys for Web Push (generates on first run)
|
||||
from app.push.vapid import ensure_vapid_keys
|
||||
|
||||
try:
|
||||
await ensure_vapid_keys()
|
||||
except Exception:
|
||||
logger.warning("Failed to initialize VAPID keys for Web Push", exc_info=True)
|
||||
|
||||
# Ensure default channels exist in the database even before the radio
|
||||
# connects. Without this, a fresh or disconnected instance would return
|
||||
# zero channels from GET /channels until the first successful radio sync.
|
||||
@@ -185,6 +194,7 @@ app.include_router(packets.router, prefix="/api")
|
||||
app.include_router(read_state.router, prefix="/api")
|
||||
app.include_router(settings.router, prefix="/api")
|
||||
app.include_router(statistics.router, prefix="/api")
|
||||
app.include_router(push.router, prefix="/api")
|
||||
app.include_router(ws.router, prefix="/api")
|
||||
|
||||
# Serve frontend static files in production
|
||||
|
||||
47
app/migrations/_057_web_push.py
Normal file
47
app/migrations/_057_web_push.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||
"""Add VAPID key columns and push_subscriptions table for Web Push."""
|
||||
|
||||
# VAPID key pair stored in app_settings (one per instance)
|
||||
table_check = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
if await table_check.fetchone():
|
||||
cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
|
||||
if "vapid_private_key" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN vapid_private_key TEXT DEFAULT ''"
|
||||
)
|
||||
if "vapid_public_key" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN vapid_public_key TEXT DEFAULT ''"
|
||||
)
|
||||
|
||||
# Push subscriptions — one row per browser
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
filter_mode TEXT NOT NULL DEFAULT 'all_messages',
|
||||
filter_conversations TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_success_at INTEGER,
|
||||
failure_count INTEGER DEFAULT 0,
|
||||
UNIQUE(endpoint)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
0
app/push/__init__.py
Normal file
0
app/push/__init__.py
Normal file
148
app/push/manager.py
Normal file
148
app/push/manager.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Web Push dispatch manager.
|
||||
|
||||
Handles filtering subscriptions by their preferences and sending push
|
||||
notifications concurrently when a new message arrives.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pywebpush import WebPushException
|
||||
|
||||
from app.push.send import send_push
|
||||
from app.push.vapid import get_vapid_private_key
|
||||
from app.repository.push_subscriptions import PushSubscriptionRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEND_TIMEOUT = 10 # seconds per push send
|
||||
_VAPID_CLAIMS = {"sub": "mailto:noreply@meshcore.local"}
|
||||
|
||||
|
||||
def _state_key_for_message(data: dict) -> str:
|
||||
"""Derive the conversation state key from a message event payload."""
|
||||
msg_type = data.get("type", "")
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
if msg_type == "PRIV":
|
||||
return f"contact-{conversation_key}"
|
||||
return f"channel-{conversation_key}"
|
||||
|
||||
|
||||
def _matches_filter(sub: dict, data: dict) -> bool:
|
||||
"""Check whether a message event matches a subscription's filter."""
|
||||
mode = sub.get("filter_mode", "all_messages")
|
||||
if mode == "all_messages":
|
||||
return True
|
||||
if mode == "all_dms":
|
||||
return data.get("type") == "PRIV"
|
||||
if mode == "selected":
|
||||
key = _state_key_for_message(data)
|
||||
return key in (sub.get("filter_conversations") or [])
|
||||
return False
|
||||
|
||||
|
||||
def _build_payload(data: dict) -> str:
|
||||
"""Build the push notification JSON payload from a message event."""
|
||||
msg_type = data.get("type", "")
|
||||
text = data.get("text", "")
|
||||
sender_name = data.get("sender_name") or ""
|
||||
channel_name = data.get("channel_name") or ""
|
||||
|
||||
if msg_type == "PRIV":
|
||||
title = f"Message from {sender_name}" if sender_name else "New direct message"
|
||||
body = text
|
||||
else:
|
||||
# Channel messages include "SenderName: text" in the text field
|
||||
title = f"#{channel_name}" if channel_name else "Channel message"
|
||||
body = text
|
||||
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
if msg_type == "PRIV":
|
||||
url_hash = f"#contact/{conversation_key}"
|
||||
else:
|
||||
url_hash = f"#channel/{conversation_key}"
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"title": title,
|
||||
"body": body,
|
||||
"tag": f"meshcore-{data.get('id', '')}",
|
||||
"url_hash": url_hash,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _subscription_info(sub: dict) -> dict:
|
||||
"""Build the subscription_info dict that pywebpush expects."""
|
||||
return {
|
||||
"endpoint": sub["endpoint"],
|
||||
"keys": {
|
||||
"p256dh": sub["p256dh"],
|
||||
"auth": sub["auth"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class PushManager:
|
||||
async def dispatch_message(self, data: dict) -> None:
|
||||
"""Send push notifications for a message event to matching subscriptions."""
|
||||
# Don't notify for messages the operator just sent themselves
|
||||
if data.get("outgoing"):
|
||||
return
|
||||
|
||||
try:
|
||||
subs = await PushSubscriptionRepository.get_all()
|
||||
except Exception:
|
||||
logger.debug("Push dispatch: failed to load subscriptions", exc_info=True)
|
||||
return
|
||||
|
||||
if not subs:
|
||||
return
|
||||
|
||||
matching = [s for s in subs if _matches_filter(s, data)]
|
||||
if not matching:
|
||||
return
|
||||
|
||||
payload = _build_payload(data)
|
||||
vapid_key = get_vapid_private_key()
|
||||
if not vapid_key:
|
||||
logger.debug("Push dispatch: no VAPID key configured, skipping")
|
||||
return
|
||||
|
||||
tasks = [self._send_one(sub, payload, vapid_key) for sub in matching]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def _send_one(self, sub: dict, payload: str, vapid_key: str) -> None:
|
||||
sub_id = sub["id"]
|
||||
try:
|
||||
async with asyncio.timeout(_SEND_TIMEOUT):
|
||||
await send_push(
|
||||
subscription_info=_subscription_info(sub),
|
||||
payload=payload,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=_VAPID_CLAIMS,
|
||||
)
|
||||
await PushSubscriptionRepository.record_success(sub_id)
|
||||
except WebPushException as e:
|
||||
status = getattr(e, "response", None)
|
||||
status_code = getattr(status, "status_code", 0) if status else 0
|
||||
if status_code in (404, 410):
|
||||
logger.info(
|
||||
"Push subscription expired (HTTP %d), removing %s",
|
||||
status_code,
|
||||
sub_id,
|
||||
)
|
||||
await PushSubscriptionRepository.delete(sub_id)
|
||||
else:
|
||||
logger.warning("Push send failed for %s: %s", sub_id, e)
|
||||
await PushSubscriptionRepository.record_failure(sub_id)
|
||||
except TimeoutError:
|
||||
logger.warning("Push send timed out for %s", sub_id)
|
||||
await PushSubscriptionRepository.record_failure(sub_id)
|
||||
except Exception:
|
||||
logger.debug("Push send error for %s", sub_id, exc_info=True)
|
||||
await PushSubscriptionRepository.record_failure(sub_id)
|
||||
|
||||
|
||||
push_manager = PushManager()
|
||||
45
app/push/send.py
Normal file
45
app/push/send.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Thin wrapper around pywebpush for sending push notifications.
|
||||
|
||||
Isolates the pywebpush dependency and runs the synchronous send in
|
||||
a thread executor to avoid blocking the event loop.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from pywebpush import webpush
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_push(
|
||||
subscription_info: dict,
|
||||
payload: str,
|
||||
vapid_private_key: str,
|
||||
vapid_claims: dict,
|
||||
) -> int:
|
||||
"""Send an encrypted push notification.
|
||||
|
||||
Args:
|
||||
subscription_info: {"endpoint": ..., "keys": {"p256dh": ..., "auth": ...}}
|
||||
payload: JSON string to encrypt and send
|
||||
vapid_private_key: PEM-encoded VAPID private key
|
||||
vapid_claims: {"sub": "mailto:..."} or {"sub": "https://..."}
|
||||
|
||||
Returns:
|
||||
HTTP status code from the push service.
|
||||
|
||||
Raises:
|
||||
WebPushException: on push service error (caller handles 404/410 cleanup).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=payload,
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_claims=vapid_claims,
|
||||
),
|
||||
)
|
||||
return response.status_code # type: ignore[union-attr]
|
||||
65
app/push/vapid.py
Normal file
65
app/push/vapid.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""VAPID key management for Web Push.
|
||||
|
||||
Generates a P-256 key pair on first use and caches it in app_settings.
|
||||
The public key is served to browsers for PushManager.subscribe().
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from py_vapid import Vapid
|
||||
|
||||
from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_cached_private_key: str = ""
|
||||
_cached_public_key: str = ""
|
||||
|
||||
|
||||
async def ensure_vapid_keys() -> tuple[str, str]:
|
||||
"""Read or generate VAPID keys. Call once at startup after DB connect."""
|
||||
global _cached_private_key, _cached_public_key
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT vapid_private_key, vapid_public_key FROM app_settings WHERE id = 1"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row and row["vapid_private_key"] and row["vapid_public_key"]:
|
||||
_cached_private_key = row["vapid_private_key"]
|
||||
_cached_public_key = row["vapid_public_key"]
|
||||
logger.info("VAPID keys loaded from database")
|
||||
return _cached_private_key, _cached_public_key
|
||||
|
||||
# Generate new key pair
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
|
||||
# Private key as PEM for pywebpush
|
||||
_cached_private_key = vapid.private_pem().decode("utf-8")
|
||||
|
||||
# Public key as uncompressed P-256 point, base64url-encoded (no padding)
|
||||
# for the browser Push API's applicationServerKey
|
||||
raw_pub = vapid.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) # type: ignore[union-attr]
|
||||
_cached_public_key = base64.urlsafe_b64encode(raw_pub).rstrip(b"=").decode("ascii")
|
||||
|
||||
await db.conn.execute(
|
||||
"UPDATE app_settings SET vapid_private_key = ?, vapid_public_key = ? WHERE id = 1",
|
||||
(_cached_private_key, _cached_public_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
logger.info("Generated and stored new VAPID key pair")
|
||||
|
||||
return _cached_private_key, _cached_public_key
|
||||
|
||||
|
||||
def get_vapid_public_key() -> str:
|
||||
"""Return the cached VAPID public key (base64url). Must call ensure_vapid_keys() first."""
|
||||
return _cached_public_key
|
||||
|
||||
|
||||
def get_vapid_private_key() -> str:
|
||||
"""Return the cached VAPID private key (PEM). Must call ensure_vapid_keys() first."""
|
||||
return _cached_private_key
|
||||
145
app/repository/push_subscriptions.py
Normal file
145
app/repository/push_subscriptions.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Repository for push_subscriptions table."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
result = {
|
||||
"id": row["id"],
|
||||
"endpoint": row["endpoint"],
|
||||
"p256dh": row["p256dh"],
|
||||
"auth": row["auth"],
|
||||
"label": row["label"] or "",
|
||||
"filter_mode": row["filter_mode"] or "all_messages",
|
||||
"filter_conversations": json.loads(row["filter_conversations"])
|
||||
if row["filter_conversations"]
|
||||
else [],
|
||||
"created_at": row["created_at"] or 0,
|
||||
"last_success_at": row["last_success_at"],
|
||||
"failure_count": row["failure_count"] or 0,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
class PushSubscriptionRepository:
|
||||
@staticmethod
|
||||
async def create(
|
||||
endpoint: str,
|
||||
p256dh: str,
|
||||
auth: str,
|
||||
label: str = "",
|
||||
filter_mode: str = "all_messages",
|
||||
filter_conversations: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create or upsert a push subscription (keyed by endpoint)."""
|
||||
sub_id = str(uuid.uuid4())
|
||||
now = int(time.time())
|
||||
convos_json = json.dumps(filter_conversations or [])
|
||||
|
||||
# Upsert: if endpoint already exists, update keys/label but keep the ID
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO push_subscriptions
|
||||
(id, endpoint, p256dh, auth, label, filter_mode,
|
||||
filter_conversations, created_at, failure_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
label = CASE WHEN excluded.label != '' THEN excluded.label ELSE push_subscriptions.label END,
|
||||
failure_count = 0
|
||||
""",
|
||||
(sub_id, endpoint, p256dh, auth, label, filter_mode, convos_json, now),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
# Return the actual row (may be existing on upsert)
|
||||
return await PushSubscriptionRepository.get_by_endpoint(endpoint) # type: ignore[return-value]
|
||||
|
||||
@staticmethod
|
||||
async def get(subscription_id: str) -> dict[str, Any] | None:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM push_subscriptions WHERE id = ?", (subscription_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
@staticmethod
|
||||
async def get_by_endpoint(endpoint: str) -> dict[str, Any] | None:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM push_subscriptions WHERE endpoint = ?", (endpoint,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> list[dict[str, Any]]:
|
||||
cursor = await db.conn.execute("SELECT * FROM push_subscriptions ORDER BY created_at DESC")
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def update(subscription_id: str, **fields: Any) -> dict[str, Any] | None:
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
for key in ("label", "filter_mode"):
|
||||
if key in fields:
|
||||
updates.append(f"{key} = ?")
|
||||
params.append(fields[key])
|
||||
|
||||
if "filter_conversations" in fields:
|
||||
updates.append("filter_conversations = ?")
|
||||
params.append(json.dumps(fields["filter_conversations"]))
|
||||
|
||||
if not updates:
|
||||
return await PushSubscriptionRepository.get(subscription_id)
|
||||
|
||||
params.append(subscription_id)
|
||||
await db.conn.execute(
|
||||
f"UPDATE push_subscriptions SET {', '.join(updates)} WHERE id = ?",
|
||||
params,
|
||||
)
|
||||
await db.conn.commit()
|
||||
return await PushSubscriptionRepository.get(subscription_id)
|
||||
|
||||
@staticmethod
|
||||
async def delete(subscription_id: str) -> bool:
|
||||
cursor = await db.conn.execute(
|
||||
"DELETE FROM push_subscriptions WHERE id = ?", (subscription_id,)
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def delete_by_endpoint(endpoint: str) -> bool:
|
||||
cursor = await db.conn.execute(
|
||||
"DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,)
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def record_success(subscription_id: str) -> None:
|
||||
now = int(time.time())
|
||||
await db.conn.execute(
|
||||
"UPDATE push_subscriptions SET last_success_at = ?, failure_count = 0 WHERE id = ?",
|
||||
(now, subscription_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def record_failure(subscription_id: str) -> None:
|
||||
await db.conn.execute(
|
||||
"UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = ?",
|
||||
(subscription_id,),
|
||||
)
|
||||
await db.conn.commit()
|
||||
132
app/routers/push.py
Normal file
132
app/routers/push.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Web Push subscription management endpoints."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.push.send import send_push
|
||||
from app.push.vapid import get_vapid_private_key, get_vapid_public_key
|
||||
from app.repository.push_subscriptions import PushSubscriptionRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/push", tags=["push"])
|
||||
|
||||
|
||||
# ── Request/response models ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class VapidPublicKeyResponse(BaseModel):
|
||||
public_key: str
|
||||
|
||||
|
||||
class PushSubscribeRequest(BaseModel):
|
||||
endpoint: str = Field(min_length=1)
|
||||
p256dh: str = Field(min_length=1)
|
||||
auth: str = Field(min_length=1)
|
||||
label: str = ""
|
||||
|
||||
|
||||
class PushSubscriptionUpdate(BaseModel):
|
||||
label: str | None = None
|
||||
filter_mode: str | None = None
|
||||
filter_conversations: list[str] | None = None
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
|
||||
async def vapid_public_key() -> VapidPublicKeyResponse:
|
||||
"""Return the VAPID public key for browser PushManager.subscribe()."""
|
||||
key = get_vapid_public_key()
|
||||
if not key:
|
||||
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
|
||||
return VapidPublicKeyResponse(public_key=key)
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe(body: PushSubscribeRequest) -> dict:
|
||||
"""Register or update a push subscription. Upserts by endpoint."""
|
||||
sub = await PushSubscriptionRepository.create(
|
||||
endpoint=body.endpoint,
|
||||
p256dh=body.p256dh,
|
||||
auth=body.auth,
|
||||
label=body.label,
|
||||
)
|
||||
return sub
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
async def list_subscriptions() -> list[dict]:
|
||||
"""List all push subscriptions."""
|
||||
return await PushSubscriptionRepository.get_all()
|
||||
|
||||
|
||||
@router.patch("/subscriptions/{subscription_id}")
|
||||
async def update_subscription(subscription_id: str, body: PushSubscriptionUpdate) -> dict:
|
||||
"""Update a subscription's label or filter preferences."""
|
||||
existing = await PushSubscriptionRepository.get(subscription_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
|
||||
updates = {}
|
||||
if body.label is not None:
|
||||
updates["label"] = body.label
|
||||
if body.filter_mode is not None:
|
||||
if body.filter_mode not in ("all_messages", "all_dms", "selected"):
|
||||
raise HTTPException(status_code=400, detail="Invalid filter_mode")
|
||||
updates["filter_mode"] = body.filter_mode
|
||||
if body.filter_conversations is not None:
|
||||
updates["filter_conversations"] = body.filter_conversations
|
||||
|
||||
result = await PushSubscriptionRepository.update(subscription_id, **updates)
|
||||
return result or existing
|
||||
|
||||
|
||||
@router.delete("/subscriptions/{subscription_id}")
|
||||
async def unsubscribe(subscription_id: str) -> dict:
|
||||
"""Delete a push subscription."""
|
||||
deleted = await PushSubscriptionRepository.delete(subscription_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/subscriptions/{subscription_id}/test")
|
||||
async def test_push(subscription_id: str) -> dict:
|
||||
"""Send a test notification to a subscription."""
|
||||
sub = await PushSubscriptionRepository.get(subscription_id)
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
|
||||
vapid_key = get_vapid_private_key()
|
||||
if not vapid_key:
|
||||
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
|
||||
|
||||
import json
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"title": "RemoteTerm Test",
|
||||
"body": "Push notifications are working!",
|
||||
"tag": "meshcore-test",
|
||||
"url_hash": "",
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
await send_push(
|
||||
subscription_info={
|
||||
"endpoint": sub["endpoint"],
|
||||
"keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]},
|
||||
},
|
||||
payload=payload,
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims={"sub": "mailto:noreply@meshcore.local"},
|
||||
)
|
||||
return {"status": "sent"}
|
||||
except Exception as e:
|
||||
logger.warning("Test push failed: %s", e)
|
||||
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None
|
||||
@@ -108,6 +108,10 @@ def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> No
|
||||
|
||||
if event_type == "message":
|
||||
asyncio.create_task(fanout_manager.broadcast_message(data))
|
||||
|
||||
from app.push.manager import push_manager
|
||||
|
||||
asyncio.create_task(push_manager.dispatch_message(data))
|
||||
elif event_type == "raw_packet":
|
||||
asyncio.create_task(fanout_manager.broadcast_raw(data))
|
||||
elif event_type == "contact":
|
||||
|
||||
Reference in New Issue
Block a user