Browse Source

Restored some files that were deleted by mistake

maziggy 5 months ago
parent
commit
2f1ac92598

+ 4 - 3
backend/app/api/routes/notifications.py

@@ -134,10 +134,11 @@ async def create_notification_provider(
 @router.post("/test-config", response_model=NotificationTestResponse)
 async def test_notification_config(
     test_request: NotificationTestRequest,
+    db: AsyncSession = Depends(get_db),
 ):
     """Test notification configuration before saving."""
     success, message = await notification_service.send_test_notification(
-        test_request.provider_type.value, test_request.config
+        test_request.provider_type.value, test_request.config, db
     )
 
     return NotificationTestResponse(success=success, message=message)
@@ -161,7 +162,7 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
     for provider in providers:
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
         success, message = await notification_service.send_test_notification(
-            provider.provider_type, config
+            provider.provider_type, config, db
         )
 
         # Update provider status
@@ -415,7 +416,7 @@ async def test_notification_provider(
 
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
     success, message = await notification_service.send_test_notification(
-        provider.provider_type, config
+        provider.provider_type, config, db
     )
 
     # Update provider status

+ 306 - 0
backend/app/api/routes/push.py

@@ -0,0 +1,306 @@
+"""API routes for Web Push notifications."""
+
+import base64
+import json
+import logging
+from datetime import datetime
+
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import get_db
+from backend.app.models.notification import PushSubscription
+from backend.app.models.settings import Settings
+from backend.app.schemas.notification import (
+    PushSubscriptionCreate,
+    PushSubscriptionResponse,
+    PushSubscriptionUpdate,
+    VapidPublicKeyResponse,
+)
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/push", tags=["push"])
+
+# Settings keys for VAPID
+VAPID_PRIVATE_KEY = "vapid_private_key"
+VAPID_PUBLIC_KEY = "vapid_public_key"
+VAPID_CLAIMS_EMAIL = "vapid_claims_email"
+
+
+def _generate_vapid_keys() -> tuple[str, str]:
+    """Generate VAPID key pair using cryptography library."""
+    # Generate private key
+    private_key = ec.generate_private_key(ec.SECP256R1())
+
+    # Get private key in PEM format
+    private_pem = private_key.private_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PrivateFormat.PKCS8,
+        encryption_algorithm=serialization.NoEncryption()
+    ).decode("utf-8")
+
+    # Get public key in uncompressed point format (X9.62)
+    public_key = private_key.public_key()
+    public_bytes = public_key.public_bytes(
+        encoding=serialization.Encoding.X962,
+        format=serialization.PublicFormat.UncompressedPoint
+    )
+
+    # Convert to URL-safe base64 (no padding)
+    public_b64 = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode('ascii')
+
+    return private_pem, public_b64
+
+
+async def get_or_create_vapid_keys(db: AsyncSession) -> tuple[str, str]:
+    """Get existing VAPID keys or generate new ones."""
+    # Try to get existing keys
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([VAPID_PRIVATE_KEY, VAPID_PUBLIC_KEY]))
+    )
+    settings = {s.key: s.value for s in result.scalars().all()}
+
+    if VAPID_PRIVATE_KEY in settings and VAPID_PUBLIC_KEY in settings:
+        return settings[VAPID_PRIVATE_KEY], settings[VAPID_PUBLIC_KEY]
+
+    # Generate new keys
+    logger.info("Generating new VAPID keys for Web Push")
+    private_key, public_key = _generate_vapid_keys()
+
+    # Store keys in database
+    for key, value in [(VAPID_PRIVATE_KEY, private_key), (VAPID_PUBLIC_KEY, public_key)]:
+        existing = await db.execute(select(Settings).where(Settings.key == key))
+        setting = existing.scalar_one_or_none()
+        if setting:
+            setting.value = value
+        else:
+            db.add(Settings(key=key, value=value))
+
+    await db.commit()
+    logger.info("VAPID keys generated and stored")
+
+    return private_key, public_key
+
+
+async def get_vapid_claims_email(db: AsyncSession) -> str:
+    """Get the email for VAPID claims (defaults to a placeholder)."""
+    result = await db.execute(select(Settings).where(Settings.key == VAPID_CLAIMS_EMAIL))
+    setting = result.scalar_one_or_none()
+    return setting.value if setting else "mailto:bambuddy@localhost"
+
+
+@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
+async def get_vapid_public_key(db: AsyncSession = Depends(get_db)):
+    """Get the VAPID public key for push subscription."""
+    _, public_key = await get_or_create_vapid_keys(db)
+    return VapidPublicKeyResponse(public_key=public_key)
+
+
+@router.get("/subscriptions", response_model=list[PushSubscriptionResponse])
+async def list_subscriptions(db: AsyncSession = Depends(get_db)):
+    """List all push subscriptions."""
+    result = await db.execute(
+        select(PushSubscription).order_by(PushSubscription.created_at.desc())
+    )
+    return result.scalars().all()
+
+
+@router.post("/subscribe", response_model=PushSubscriptionResponse)
+async def subscribe(
+    subscription: PushSubscriptionCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Subscribe a browser to push notifications."""
+    # Check if subscription already exists (by endpoint)
+    result = await db.execute(
+        select(PushSubscription).where(PushSubscription.endpoint == subscription.endpoint)
+    )
+    existing = result.scalar_one_or_none()
+
+    if existing:
+        # Update existing subscription
+        existing.p256dh_key = subscription.p256dh_key
+        existing.auth_key = subscription.auth_key
+        existing.user_agent = subscription.user_agent
+        if subscription.name:
+            existing.name = subscription.name
+        existing.enabled = True
+        existing.updated_at = datetime.utcnow()
+        await db.commit()
+        await db.refresh(existing)
+        logger.info(f"Updated push subscription: {existing.name or existing.id}")
+        return existing
+
+    # Create new subscription
+    # Generate a default name from user agent if not provided
+    name = subscription.name
+    if not name and subscription.user_agent:
+        # Extract browser name from user agent
+        ua = subscription.user_agent.lower()
+        if "chrome" in ua and "edg" not in ua:
+            name = "Chrome"
+        elif "firefox" in ua:
+            name = "Firefox"
+        elif "safari" in ua and "chrome" not in ua:
+            name = "Safari"
+        elif "edg" in ua:
+            name = "Edge"
+        else:
+            name = "Browser"
+
+        # Add device hint
+        if "mobile" in ua or "android" in ua or "iphone" in ua:
+            name += " (Mobile)"
+        else:
+            name += " (Desktop)"
+
+    new_subscription = PushSubscription(
+        endpoint=subscription.endpoint,
+        p256dh_key=subscription.p256dh_key,
+        auth_key=subscription.auth_key,
+        name=name,
+        user_agent=subscription.user_agent,
+        enabled=True,
+    )
+    db.add(new_subscription)
+    await db.commit()
+    await db.refresh(new_subscription)
+
+    logger.info(f"New push subscription created: {new_subscription.name or new_subscription.id}")
+    return new_subscription
+
+
+@router.patch("/subscriptions/{subscription_id}", response_model=PushSubscriptionResponse)
+async def update_subscription(
+    subscription_id: int,
+    update: PushSubscriptionUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a push subscription."""
+    result = await db.execute(
+        select(PushSubscription).where(PushSubscription.id == subscription_id)
+    )
+    subscription = result.scalar_one_or_none()
+
+    if not subscription:
+        raise HTTPException(status_code=404, detail="Subscription not found")
+
+    if update.name is not None:
+        subscription.name = update.name
+    if update.enabled is not None:
+        subscription.enabled = update.enabled
+
+    subscription.updated_at = datetime.utcnow()
+    await db.commit()
+    await db.refresh(subscription)
+
+    return subscription
+
+
+@router.delete("/subscriptions/{subscription_id}")
+async def delete_subscription(
+    subscription_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a push subscription."""
+    result = await db.execute(
+        select(PushSubscription).where(PushSubscription.id == subscription_id)
+    )
+    subscription = result.scalar_one_or_none()
+
+    if not subscription:
+        raise HTTPException(status_code=404, detail="Subscription not found")
+
+    await db.delete(subscription)
+    await db.commit()
+
+    return {"message": "Subscription deleted"}
+
+
+@router.post("/unsubscribe")
+async def unsubscribe_by_endpoint(
+    endpoint: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Unsubscribe by endpoint URL (called when browser unsubscribes)."""
+    result = await db.execute(
+        select(PushSubscription).where(PushSubscription.endpoint == endpoint)
+    )
+    subscription = result.scalar_one_or_none()
+
+    if subscription:
+        await db.delete(subscription)
+        await db.commit()
+        logger.info(f"Push subscription removed by endpoint: {subscription.name or subscription.id}")
+
+    return {"message": "Unsubscribed"}
+
+
+@router.post("/test")
+async def test_push_notification(db: AsyncSession = Depends(get_db)):
+    """Send a test push notification to all subscribed browsers."""
+    from pywebpush import webpush, WebPushException
+
+    private_key, public_key = await get_or_create_vapid_keys(db)
+    claims_email = await get_vapid_claims_email(db)
+
+    # Get all enabled subscriptions
+    result = await db.execute(
+        select(PushSubscription).where(PushSubscription.enabled == True)
+    )
+    subscriptions = result.scalars().all()
+
+    if not subscriptions:
+        raise HTTPException(status_code=400, detail="No push subscriptions found")
+
+    success_count = 0
+    error_count = 0
+    errors = []
+
+    for sub in subscriptions:
+        subscription_info = {
+            "endpoint": sub.endpoint,
+            "keys": {
+                "p256dh": sub.p256dh_key,
+                "auth": sub.auth_key,
+            },
+        }
+
+        payload = json.dumps({
+            "title": "BamBuddy Test",
+            "body": "Push notifications are working!",
+            "url": "/",
+        })
+
+        try:
+            webpush(
+                subscription_info=subscription_info,
+                data=payload,
+                vapid_private_key=private_key,
+                vapid_claims={"sub": claims_email},
+            )
+            sub.last_success = datetime.utcnow()
+            success_count += 1
+            logger.info(f"Test push sent to: {sub.name or sub.id}")
+        except WebPushException as e:
+            error_count += 1
+            sub.last_error = str(e)
+            sub.last_error_at = datetime.utcnow()
+            errors.append(f"{sub.name or sub.id}: {str(e)}")
+            logger.error(f"Push error for {sub.name or sub.id}: {e}")
+
+            # If subscription is gone (410), remove it
+            if e.response and e.response.status_code == 410:
+                await db.delete(sub)
+                logger.info(f"Removed expired subscription: {sub.name or sub.id}")
+
+    await db.commit()
+
+    return {
+        "success": success_count > 0,
+        "message": f"Sent to {success_count} device(s), {error_count} error(s)",
+        "errors": errors if errors else None,
+    }

+ 2 - 1
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_, delete
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook, ams_history
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook, ams_history, push
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -1225,6 +1225,7 @@ app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
+app.include_router(push.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 2 - 1
backend/app/models/__init__.py

@@ -6,7 +6,7 @@ from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.notification_template import NotificationTemplate
-from backend.app.models.notification import NotificationLog
+from backend.app.models.notification import NotificationLog, PushSubscription
 from backend.app.models.project import Project
 from backend.app.models.api_key import APIKey
 from backend.app.models.ams_history import AMSSensorHistory
@@ -23,6 +23,7 @@ __all__ = [
     "KProfileNote",
     "NotificationTemplate",
     "NotificationLog",
+    "PushSubscription",
     "Project",
     "APIKey",
     "AMSSensorHistory",

+ 27 - 0
backend/app/models/notification.py

@@ -101,3 +101,30 @@ class NotificationProvider(Base):
     printer = relationship("Printer", back_populates="notification_providers")
     logs = relationship("NotificationLog", back_populates="provider", cascade="all, delete-orphan")
     digest_queue = relationship("NotificationDigestQueue", back_populates="provider", cascade="all, delete-orphan")
+
+
+class PushSubscription(Base):
+    """Model for storing Web Push subscriptions (browser endpoints)."""
+
+    __tablename__ = "push_subscriptions"
+
+    id = Column(Integer, primary_key=True, index=True)
+
+    # Subscription data from browser's PushManager
+    endpoint = Column(Text, nullable=False, unique=True)  # Push service URL
+    p256dh_key = Column(String(255), nullable=False)  # Public key for encryption
+    auth_key = Column(String(255), nullable=False)  # Auth secret
+
+    # User-friendly name for this subscription
+    name = Column(String(100), nullable=True)  # e.g., "Chrome on Phone", "Firefox Desktop"
+    user_agent = Column(String(255), nullable=True)  # Browser user agent for identification
+
+    # Status
+    enabled = Column(Boolean, default=True)
+    last_success = Column(DateTime, nullable=True)
+    last_error = Column(Text, nullable=True)
+    last_error_at = Column(DateTime, nullable=True)
+
+    # Timestamps
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 42 - 0
backend/app/schemas/notification.py

@@ -15,6 +15,7 @@ class ProviderType(str, Enum):
     PUSHOVER = "pushover"
     TELEGRAM = "telegram"
     EMAIL = "email"
+    WEBPUSH = "webpush"
 
 
 class NotificationProviderBase(BaseModel):
@@ -215,3 +216,44 @@ class NotificationLogStats(BaseModel):
     failure_count: int
     by_event_type: dict[str, int]
     by_provider: dict[str, int]
+
+
+# Push Subscription schemas
+class PushSubscriptionCreate(BaseModel):
+    """Schema for creating a push subscription."""
+
+    endpoint: str = Field(..., description="Push service endpoint URL")
+    p256dh_key: str = Field(..., description="P-256 public key for encryption")
+    auth_key: str = Field(..., description="Authentication secret")
+    name: str | None = Field(default=None, max_length=100, description="User-friendly name")
+    user_agent: str | None = Field(default=None, max_length=255, description="Browser user agent")
+
+
+class PushSubscriptionResponse(BaseModel):
+    """Schema for push subscription API responses."""
+
+    id: int
+    endpoint: str
+    name: str | None
+    user_agent: str | None
+    enabled: bool
+    last_success: datetime | None
+    last_error: str | None
+    last_error_at: datetime | None
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class PushSubscriptionUpdate(BaseModel):
+    """Schema for updating a push subscription."""
+
+    name: str | None = Field(default=None, max_length=100)
+    enabled: bool | None = None
+
+
+class VapidPublicKeyResponse(BaseModel):
+    """Schema for VAPID public key response."""
+
+    public_key: str = Field(..., description="VAPID public key for push subscription")

+ 94 - 4
backend/app/services/notification_service.py

@@ -12,11 +12,13 @@ from typing import Any
 from urllib.parse import quote
 
 import httpx
+from pywebpush import webpush, WebPushException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue
+from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue, PushSubscription
 from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.settings import Settings
 
 logger = logging.getLogger(__name__)
 
@@ -157,6 +159,10 @@ class NotificationService:
                 return await self._send_discord(config, title, message)
             elif provider_type == "webhook":
                 return await self._send_webhook(config, title, message)
+            elif provider_type == "webpush":
+                if db is None:
+                    return False, "Database session required for webpush test"
+                return await self._send_webpush(db, title, message)
             else:
                 return False, f"Unknown provider type: {provider_type}"
         except Exception as e:
@@ -379,8 +385,88 @@ class NotificationService:
         except Exception as e:
             return False, f"Webhook error: {str(e)}"
 
+    async def _get_vapid_keys(self, db: AsyncSession) -> tuple[str | None, str | None]:
+        """Get VAPID keys from database settings."""
+        result = await db.execute(
+            select(Settings).where(Settings.key.in_(["vapid_private_key", "vapid_public_key"]))
+        )
+        settings = {s.key: s.value for s in result.scalars().all()}
+        return settings.get("vapid_private_key"), settings.get("vapid_public_key")
+
+    async def _get_vapid_claims_email(self, db: AsyncSession) -> str:
+        """Get the email for VAPID claims."""
+        result = await db.execute(select(Settings).where(Settings.key == "vapid_claims_email"))
+        setting = result.scalar_one_or_none()
+        return setting.value if setting else "mailto:bambuddy@localhost"
+
+    async def _send_webpush(self, db: AsyncSession, title: str, message: str) -> tuple[bool, str]:
+        """Send notification via Web Push to all subscribed browsers."""
+        private_key, public_key = await self._get_vapid_keys(db)
+
+        if not private_key or not public_key:
+            return False, "VAPID keys not configured. Subscribe a browser first."
+
+        claims_email = await self._get_vapid_claims_email(db)
+
+        # Get all enabled push subscriptions
+        result = await db.execute(
+            select(PushSubscription).where(PushSubscription.enabled == True)
+        )
+        subscriptions = list(result.scalars().all())
+
+        if not subscriptions:
+            return False, "No push subscriptions found"
+
+        success_count = 0
+        error_count = 0
+        last_error = ""
+
+        for sub in subscriptions:
+            subscription_info = {
+                "endpoint": sub.endpoint,
+                "keys": {
+                    "p256dh": sub.p256dh_key,
+                    "auth": sub.auth_key,
+                },
+            }
+
+            payload = json.dumps({
+                "title": title,
+                "body": message,
+                "url": "/",
+            })
+
+            try:
+                webpush(
+                    subscription_info=subscription_info,
+                    data=payload,
+                    vapid_private_key=private_key,
+                    vapid_claims={"sub": claims_email},
+                )
+                sub.last_success = datetime.utcnow()
+                success_count += 1
+                logger.debug(f"Web push sent to: {sub.name or sub.id}")
+            except WebPushException as e:
+                error_count += 1
+                sub.last_error = str(e)
+                sub.last_error_at = datetime.utcnow()
+                last_error = str(e)
+                logger.warning(f"Web push error for {sub.name or sub.id}: {e}")
+
+                # If subscription is gone (410), mark it as disabled
+                if e.response and e.response.status_code == 410:
+                    sub.enabled = False
+                    logger.info(f"Disabled expired subscription: {sub.name or sub.id}")
+
+        await db.commit()
+
+        if success_count > 0:
+            return True, f"Sent to {success_count} device(s)"
+        else:
+            return False, f"Failed to send to all {error_count} device(s): {last_error}"
+
     async def _send_to_provider(
-        self, provider: NotificationProvider, title: str, message: str
+        self, provider: NotificationProvider, title: str, message: str, db: AsyncSession | None = None
     ) -> tuple[bool, str]:
         """Send notification to a specific provider."""
         # Check quiet hours
@@ -405,6 +491,10 @@ class NotificationService:
                 return await self._send_discord(config, title, message)
             elif provider.provider_type == "webhook":
                 return await self._send_webhook(config, title, message)
+            elif provider.provider_type == "webpush":
+                if db is None:
+                    return False, "Database session required for webpush"
+                return await self._send_webpush(db, title, message)
             else:
                 return False, f"Unknown provider type: {provider.provider_type}"
         except Exception as e:
@@ -497,7 +587,7 @@ class NotificationService:
         for provider in providers:
             try:
                 # Always send notification immediately
-                success, error = await self._send_to_provider(provider, title, message)
+                success, error = await self._send_to_provider(provider, title, message, db)
 
                 # Also queue for digest if enabled (digest is a summary, not a queue)
                 if provider.daily_digest_enabled and provider.daily_digest_time:
@@ -864,7 +954,7 @@ class NotificationService:
             body = "\n".join(body_parts)
 
             # Send the digest
-            success, error = await self._send_to_provider(provider, title, body)
+            success, error = await self._send_to_provider(provider, title, body, db)
 
             # Log the digest
             await self._log_notification(

+ 2 - 2
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v1';
-const STATIC_CACHE = 'bambuddy-static-v1';
+const CACHE_NAME = 'bambuddy-v4';
+const STATIC_CACHE = 'bambuddy-static-v4';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

+ 60 - 1
frontend/src/api/client.ts

@@ -764,7 +764,7 @@ export interface Filament {
 }
 
 // Notification Provider types
-export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook';
+export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'webpush';
 
 export interface NotificationProvider {
   id: number;
@@ -1907,6 +1907,29 @@ export const api = {
   // AMS History
   getAMSHistory: (printerId: number, amsId: number, hours = 24) =>
     request<AMSHistoryResponse>(`/ams-history/${printerId}/${amsId}?hours=${hours}`),
+
+  // Push Subscriptions (Browser Push Notifications)
+  getVapidPublicKey: () => request<VapidPublicKeyResponse>('/push/vapid-public-key'),
+  getPushSubscriptions: () => request<PushSubscription[]>('/push/subscriptions'),
+  subscribePush: (data: PushSubscriptionCreate) =>
+    request<PushSubscription>('/push/subscribe', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updatePushSubscription: (id: number, data: PushSubscriptionUpdate) =>
+    request<PushSubscription>(`/push/subscriptions/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deletePushSubscription: (id: number) =>
+    request<{ message: string }>(`/push/subscriptions/${id}`, { method: 'DELETE' }),
+  unsubscribePush: (endpoint: string) =>
+    request<{ message: string }>('/push/unsubscribe', {
+      method: 'POST',
+      body: JSON.stringify(endpoint),
+    }),
+  testPushNotification: () =>
+    request<PushTestResponse>('/push/test', { method: 'POST' }),
 };
 
 // AMS History types
@@ -1928,3 +1951,39 @@ export interface AMSHistoryResponse {
   max_temperature: number | null;
   avg_temperature: number | null;
 }
+
+// Push Subscription types
+export interface PushSubscription {
+  id: number;
+  endpoint: string;
+  name: string | null;
+  user_agent: string | null;
+  enabled: boolean;
+  last_success: string | null;
+  last_error: string | null;
+  last_error_at: string | null;
+  created_at: string;
+}
+
+export interface PushSubscriptionCreate {
+  endpoint: string;
+  p256dh_key: string;
+  auth_key: string;
+  name?: string;
+  user_agent?: string;
+}
+
+export interface PushSubscriptionUpdate {
+  name?: string;
+  enabled?: boolean;
+}
+
+export interface VapidPublicKeyResponse {
+  public_key: string;
+}
+
+export interface PushTestResponse {
+  success: boolean;
+  message: string;
+  errors?: string[];
+}

+ 15 - 2
frontend/src/components/AddNotificationModal.tsx

@@ -12,11 +12,12 @@ interface AddNotificationModalProps {
 }
 
 const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
+  { value: 'email', label: 'Email', description: 'SMTP email notifications' },
+  { value: 'webpush', label: 'Browser Push', description: 'Push notifications to subscribed browsers (PWA)' },
   { value: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },
   { value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
   { value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
   { value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
-  { value: 'email', label: 'Email', description: 'SMTP email notifications' },
   { value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
   { value: 'webhook', label: 'Webhook', description: 'Generic HTTP POST to any URL' },
 ];
@@ -26,7 +27,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const isEditing = !!provider;
 
   const [name, setName] = useState(provider?.name || '');
-  const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'discord');
+  const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'email');
   const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
   const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
   const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
@@ -210,6 +211,8 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false },
           { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false },
         ];
+      case 'webpush':
+        return []; // No config needed - sends to all subscribed browsers
       default:
         return [];
     }
@@ -290,6 +293,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           {/* Provider-specific configuration */}
           <div className="space-y-3">
             <p className="text-sm text-bambu-gray">Configuration</p>
+            {providerType === 'webpush' && (
+              <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg text-sm">
+                <p className="text-bambu-green font-medium mb-1">No configuration needed</p>
+                <p className="text-bambu-gray">
+                  Browser Push sends notifications to all browsers that have enabled push notifications
+                  in Settings → Notifications. Subscribe browsers using the "Browser Push Notifications"
+                  card before creating this provider.
+                </p>
+              </div>
+            )}
             {configFields.map((field) => (
               <div key={field.key}>
                 <label className="block text-sm text-bambu-gray mb-1">

+ 466 - 0
frontend/src/components/BrowserPushCard.tsx

@@ -0,0 +1,466 @@
+/**
+ * Component for managing browser push notification subscriptions.
+ */
+
+import { useState, useEffect, useCallback, Component } from 'react';
+import type { ReactNode } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { api } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+
+// Error boundary for the push card
+interface ErrorBoundaryState {
+  hasError: boolean;
+  error?: Error;
+}
+
+class BrowserPushErrorBoundary extends Component<{ children: ReactNode; className?: string }, ErrorBoundaryState> {
+  constructor(props: { children: ReactNode; className?: string }) {
+    super(props);
+    this.state = { hasError: false };
+  }
+
+  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+    return { hasError: true, error };
+  }
+
+  render() {
+    if (this.state.hasError) {
+      return (
+        <Card className={this.props.className}>
+          <CardHeader>
+            <div className="flex items-center gap-2">
+              <svg className="w-5 h-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+              </svg>
+              <h3 className="text-white font-medium">Browser Push Notifications</h3>
+            </div>
+          </CardHeader>
+          <CardContent>
+            <p className="text-red-400 text-sm">Failed to load push notification settings.</p>
+            <p className="text-bambu-gray text-xs mt-1">{this.state.error?.message}</p>
+          </CardContent>
+        </Card>
+      );
+    }
+    return this.props.children;
+  }
+}
+
+// Convert base64 URL to Uint8Array (for VAPID key)
+function urlBase64ToUint8Array(base64String: string): Uint8Array {
+  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
+  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+  for (let i = 0; i < rawData.length; ++i) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+  return outputArray;
+}
+
+// Convert ArrayBuffer to base64 URL string
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+  const bytes = new Uint8Array(buffer);
+  let binary = '';
+  for (let i = 0; i < bytes.byteLength; i++) {
+    binary += String.fromCharCode(bytes[i]);
+  }
+  return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+}
+
+interface BrowserPushCardProps {
+  className?: string;
+}
+
+function BrowserPushCardInner({ className }: BrowserPushCardProps) {
+  const queryClient = useQueryClient();
+  const [isSubscribed, setIsSubscribed] = useState(false);
+  const [currentEndpoint, setCurrentEndpoint] = useState<string | null>(null);
+  const [permissionState, setPermissionState] = useState<NotificationPermission>('default');
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  // Check if push notifications are supported
+  // Requires HTTPS (or localhost) and service worker support
+  const isSupported = typeof window !== 'undefined' &&
+    'serviceWorker' in navigator &&
+    'PushManager' in window &&
+    'Notification' in window;
+
+  // Check if running in secure context
+  const isSecureContext = typeof window !== 'undefined' && window.isSecureContext;
+
+  // Fetch VAPID public key
+  const { data: vapidData } = useQuery({
+    queryKey: ['vapid-public-key'],
+    queryFn: api.getVapidPublicKey,
+    enabled: isSupported,
+  });
+
+  // Fetch all subscriptions
+  const { data: subscriptions, isLoading: subscriptionsLoading } = useQuery({
+    queryKey: ['push-subscriptions'],
+    queryFn: api.getPushSubscriptions,
+  });
+
+  // Subscribe mutation
+  const subscribeMutation = useMutation({
+    mutationFn: api.subscribePush,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['push-subscriptions'] });
+      setIsSubscribed(true);
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  // Delete subscription mutation
+  const deleteMutation = useMutation({
+    mutationFn: api.deletePushSubscription,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['push-subscriptions'] });
+    },
+  });
+
+  // Update subscription mutation
+  const updateMutation = useMutation({
+    mutationFn: ({ id, data }: { id: number; data: { enabled?: boolean; name?: string } }) =>
+      api.updatePushSubscription(id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['push-subscriptions'] });
+    },
+  });
+
+  // Test push mutation
+  const testMutation = useMutation({
+    mutationFn: api.testPushNotification,
+  });
+
+  // Check current subscription status
+  const checkSubscription = useCallback(async () => {
+    if (!isSupported) {
+      setIsLoading(false);
+      return;
+    }
+
+    try {
+      // Safely check notification permission
+      if ('Notification' in window && Notification.permission) {
+        setPermissionState(Notification.permission);
+      }
+
+      const registration = await navigator.serviceWorker.ready;
+      const subscription = await registration.pushManager.getSubscription();
+
+      if (subscription) {
+        setCurrentEndpoint(subscription.endpoint);
+        setIsSubscribed(true);
+      } else {
+        setCurrentEndpoint(null);
+        setIsSubscribed(false);
+      }
+    } catch (err) {
+      console.error('Error checking push subscription:', err);
+      setError('Failed to check push status');
+    } finally {
+      setIsLoading(false);
+    }
+  }, [isSupported]);
+
+  useEffect(() => {
+    checkSubscription();
+  }, [checkSubscription]);
+
+  // Subscribe this browser
+  const handleSubscribe = async () => {
+    if (!vapidData?.public_key) {
+      setError('VAPID key not available');
+      return;
+    }
+
+    setError(null);
+    setIsLoading(true);
+
+    try {
+      // Request permission if needed
+      if (Notification.permission === 'default') {
+        const permission = await Notification.requestPermission();
+        setPermissionState(permission);
+        if (permission !== 'granted') {
+          setError('Notification permission denied');
+          setIsLoading(false);
+          return;
+        }
+      } else if (Notification.permission === 'denied') {
+        setError('Notifications are blocked. Please enable them in your browser settings.');
+        setIsLoading(false);
+        return;
+      }
+
+      // Get service worker registration
+      const registration = await navigator.serviceWorker.ready;
+
+      // Subscribe to push
+      const applicationServerKey = urlBase64ToUint8Array(vapidData.public_key);
+      const subscription = await registration.pushManager.subscribe({
+        userVisibleOnly: true,
+        applicationServerKey: applicationServerKey.buffer as ArrayBuffer,
+      });
+
+      // Extract keys
+      const p256dhKey = subscription.getKey('p256dh');
+      const authKey = subscription.getKey('auth');
+
+      if (!p256dhKey || !authKey) {
+        throw new Error('Failed to get subscription keys');
+      }
+
+      // Send to backend
+      await subscribeMutation.mutateAsync({
+        endpoint: subscription.endpoint,
+        p256dh_key: arrayBufferToBase64(p256dhKey),
+        auth_key: arrayBufferToBase64(authKey),
+        user_agent: navigator.userAgent,
+      });
+
+      setCurrentEndpoint(subscription.endpoint);
+    } catch (err) {
+      console.error('Error subscribing to push:', err);
+      setError(err instanceof Error ? err.message : 'Failed to subscribe');
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  // Unsubscribe this browser
+  const handleUnsubscribe = async () => {
+    setIsLoading(true);
+    try {
+      const registration = await navigator.serviceWorker.ready;
+      const subscription = await registration.pushManager.getSubscription();
+
+      if (subscription) {
+        await subscription.unsubscribe();
+      }
+
+      // Find and delete from backend
+      if (currentEndpoint && subscriptions) {
+        const sub = subscriptions.find((s) => s.endpoint === currentEndpoint);
+        if (sub) {
+          await deleteMutation.mutateAsync(sub.id);
+        }
+      }
+
+      setIsSubscribed(false);
+      setCurrentEndpoint(null);
+    } catch (err) {
+      console.error('Error unsubscribing:', err);
+      setError(err instanceof Error ? err.message : 'Failed to unsubscribe');
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  // Show message if not in secure context (HTTPS required)
+  if (!isSecureContext) {
+    return (
+      <Card className={className}>
+        <CardHeader>
+          <div className="flex items-center gap-2">
+            <svg className="w-5 h-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+            </svg>
+            <h3 className="text-white font-medium">Browser Push Notifications</h3>
+          </div>
+        </CardHeader>
+        <CardContent className="space-y-2">
+          <p className="text-yellow-400 text-sm">
+            HTTPS required for push notifications.
+          </p>
+          <p className="text-bambu-gray text-xs">
+            Push notifications require a secure connection (HTTPS). To enable this feature,
+            configure HTTPS for your BamBuddy server using a reverse proxy like Caddy or nginx.
+          </p>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  if (!isSupported) {
+    return (
+      <Card className={className}>
+        <CardHeader>
+          <div className="flex items-center gap-2">
+            <svg className="w-5 h-5 text-bambu-gray" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
+            </svg>
+            <h3 className="text-white font-medium">Browser Push Notifications</h3>
+          </div>
+        </CardHeader>
+        <CardContent className="space-y-2">
+          <p className="text-bambu-gray text-sm">
+            Push notifications are not available in this browser.
+          </p>
+          <p className="text-bambu-gray text-xs">
+            To receive push notifications on your phone, install BamBuddy as a PWA:
+            open this page on your mobile device and tap "Add to Home Screen" in your browser menu.
+          </p>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  return (
+    <Card className={className}>
+      <CardHeader>
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-2">
+            <svg className="w-5 h-5 text-bambu-green" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
+            </svg>
+            <h3 className="text-white font-medium">Browser Push Notifications</h3>
+          </div>
+          {isSubscribed && (
+            <span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded-full">
+              Subscribed
+            </span>
+          )}
+        </div>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        {/* Current browser subscription */}
+        <div className="space-y-2">
+          <p className="text-bambu-gray text-sm">
+            {isSubscribed
+              ? 'This browser is subscribed to push notifications.'
+              : 'Enable push notifications to receive alerts directly in your browser.'}
+          </p>
+
+          {error && (
+            <div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
+              {error}
+            </div>
+          )}
+
+          {permissionState === 'denied' && (
+            <div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-yellow-400 text-sm">
+              Notifications are blocked. Please enable them in your browser settings.
+            </div>
+          )}
+
+          <div className="flex gap-2">
+            {isSubscribed ? (
+              <>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={handleUnsubscribe}
+                  disabled={isLoading}
+                >
+                  Unsubscribe
+                </Button>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => testMutation.mutate()}
+                  disabled={testMutation.isPending || subscriptionsLoading}
+                >
+                  {testMutation.isPending ? 'Sending...' : 'Test'}
+                </Button>
+              </>
+            ) : (
+              <Button
+                variant="primary"
+                size="sm"
+                onClick={handleSubscribe}
+                disabled={isLoading || permissionState === 'denied'}
+              >
+                {isLoading ? 'Subscribing...' : 'Enable Notifications'}
+              </Button>
+            )}
+          </div>
+
+          {testMutation.isSuccess && (
+            <p className="text-bambu-green text-sm">{testMutation.data.message}</p>
+          )}
+          {testMutation.isError && (
+            <p className="text-red-400 text-sm">
+              {testMutation.error instanceof Error ? testMutation.error.message : 'Test failed'}
+            </p>
+          )}
+        </div>
+
+        {/* All subscriptions list */}
+        {subscriptions && subscriptions.length > 0 && (
+          <div className="border-t border-bambu-dark pt-4">
+            <h4 className="text-white text-sm font-medium mb-2">All Subscribed Browsers</h4>
+            <div className="space-y-2">
+              {subscriptions.map((sub) => (
+                <div
+                  key={sub.id}
+                  className={`flex items-center justify-between p-2 rounded ${
+                    sub.endpoint === currentEndpoint
+                      ? 'bg-bambu-green/10 border border-bambu-green/30'
+                      : 'bg-bambu-dark-secondary'
+                  }`}
+                >
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2">
+                      <span className="text-white text-sm truncate">
+                        {sub.name || 'Unknown Browser'}
+                      </span>
+                      {sub.endpoint === currentEndpoint && (
+                        <span className="text-xs text-bambu-green">(this browser)</span>
+                      )}
+                    </div>
+                    <span className="text-bambu-gray text-xs">
+                      Added {new Date(sub.created_at).toLocaleDateString()}
+                    </span>
+                  </div>
+                  <div className="flex items-center gap-2">
+                    <Toggle
+                      checked={sub.enabled}
+                      onChange={(checked) =>
+                        updateMutation.mutate({ id: sub.id, data: { enabled: checked } })
+                      }
+                    />
+                    {sub.endpoint !== currentEndpoint && (
+                      <button
+                        onClick={() => deleteMutation.mutate(sub.id)}
+                        className="p-1 text-bambu-gray hover:text-red-400 transition-colors"
+                        title="Remove"
+                      >
+                        <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                        </svg>
+                      </button>
+                    )}
+                  </div>
+                </div>
+              ))}
+            </div>
+          </div>
+        )}
+
+        {/* Info text */}
+        <p className="text-bambu-gray text-xs">
+          Browser push notifications are sent directly to your device. To receive notifications
+          for specific events, create a "Browser Push" notification provider below.
+        </p>
+      </CardContent>
+    </Card>
+  );
+}
+
+// Export with error boundary wrapper
+export function BrowserPushCard({ className }: BrowserPushCardProps) {
+  return (
+    <BrowserPushErrorBoundary className={className}>
+      <BrowserPushCardInner className={className} />
+    </BrowserPushErrorBoundary>
+  );
+}

+ 4 - 0
frontend/src/pages/SettingsPage.tsx

@@ -11,6 +11,7 @@ import { NotificationProviderCard } from '../components/NotificationProviderCard
 import { AddNotificationModal } from '../components/AddNotificationModal';
 import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
 import { NotificationLogViewer } from '../components/NotificationLogViewer';
+import { BrowserPushCard } from '../components/BrowserPushCard';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { BackupModal } from '../components/BackupModal';
 import { RestoreModal } from '../components/RestoreModal';
@@ -1269,6 +1270,9 @@ export function SettingsPage() {
               </div>
             </div>
 
+            {/* Browser Push Notifications */}
+            <BrowserPushCard className="mb-4" />
+
             {/* Notification Language Setting */}
             <Card className="mb-4">
               <CardContent className="py-3">

+ 3 - 0
requirements.txt

@@ -20,6 +20,9 @@ aioftp>=0.22.0
 # Excel Export
 openpyxl>=3.1.0
 
+# Notifications
+pywebpush>=2.0.0
+
 # Utilities
 python-multipart>=0.0.6
 aiofiles>=23.0.0

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CB_jd89Z.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CZyus6A_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-hLhCA050.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-GIyWZiRq.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CB_jd89Z.css">
+    <script type="module" crossorigin src="/assets/index-CZyus6A_.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-hLhCA050.css">
   </head>
   <body>
     <div id="root"></div>

+ 2 - 2
static/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v1';
-const STATIC_CACHE = 'bambuddy-static-v1';
+const CACHE_NAME = 'bambuddy-v4';
+const STATIC_CACHE = 'bambuddy-static-v4';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

Some files were not shown because too many files changed in this diff