diff --git a/lnbits/app.py b/lnbits/app.py index 657fa13bb..d89f87b10 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -39,7 +39,7 @@ from .core import ( core_app_extra, update_installed_extension_state, ) -from .core.services import check_admin_settings +from .core.services import check_admin_settings, check_webpush_settings from .core.views.generic import core_html_routes from .extension_manager import Extension, InstallableExtension, get_valid_extensions from .helpers import template_renderer @@ -332,6 +332,7 @@ def register_startup(app: FastAPI): # setup admin settings await check_admin_settings() + await check_webpush_settings() log_server_info() diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index dc2ad6dd9..cdf4f2e8c 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -10,10 +10,23 @@ from lnbits import bolt11 from lnbits.core.models import WalletType from lnbits.db import Connection, Database, Filters, Page from lnbits.extension_manager import InstallableExtension -from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings +from lnbits.settings import ( + AdminSettings, + SuperSettings, + WebPushSettings, + settings, +) from . import db -from .models import BalanceCheck, Payment, PaymentFilters, TinyURL, User, Wallet +from .models import ( + BalanceCheck, + Payment, + PaymentFilters, + TinyURL, + User, + Wallet, + WebPushSubscription, +) # accounts # -------- @@ -788,8 +801,16 @@ async def delete_admin_settings(): await db.execute("DELETE FROM settings") -async def update_admin_settings(data: EditableSettings): - await db.execute("UPDATE settings SET editable_settings = ?", (json.dumps(data),)) +async def update_admin_settings(data: dict): + row = await db.fetchone("SELECT editable_settings FROM settings") + if not row: + return None + editable_settings = json.loads(row["editable_settings"]) + for key, value in data.items(): + editable_settings[key] = value + await db.execute( + "UPDATE settings SET editable_settings = ?", (json.dumps(editable_settings),) + ) async def update_super_user(super_user: str) -> SuperSettings: @@ -872,3 +893,82 @@ async def delete_tinyurl(tinyurl_id: str): "DELETE FROM tiny_url WHERE id = ?", (tinyurl_id,), ) + + +# push_notification +# ----------------- + + +async def get_webpush_settings() -> Optional[WebPushSettings]: + row = await db.fetchone("SELECT * FROM webpush_settings") + if not row: + return None + vapid_keypair = json.loads(row["vapid_keypair"]) + return WebPushSettings(**vapid_keypair) + + +async def create_webpush_settings(webpush_settings: dict): + await db.execute( + "INSERT INTO webpush_settings (vapid_keypair) VALUES (?)", + (json.dumps(webpush_settings),), + ) + return await get_webpush_settings() + + +async def get_webpush_subscription( + endpoint: str, user: str +) -> Optional[WebPushSubscription]: + row = await db.fetchone( + "SELECT * FROM webpush_subscriptions WHERE endpoint = ? AND user = ?", + ( + endpoint, + user, + ), + ) + return WebPushSubscription(**dict(row)) if row else None + + +async def get_webpush_subscriptions_for_user( + user: str, +) -> List[WebPushSubscription]: + rows = await db.fetchall( + "SELECT * FROM webpush_subscriptions WHERE user = ?", + (user,), + ) + return [WebPushSubscription(**dict(row)) for row in rows] + + +async def create_webpush_subscription( + endpoint: str, user: str, data: str, host: str +) -> WebPushSubscription: + await db.execute( + """ + INSERT INTO webpush_subscriptions (endpoint, user, data, host) + VALUES (?, ?, ?, ?) + """, + ( + endpoint, + user, + data, + host, + ), + ) + subscription = await get_webpush_subscription(endpoint, user) + assert subscription, "Newly created webpush subscription couldn't be retrieved" + return subscription + + +async def delete_webpush_subscription(endpoint: str, user: str) -> None: + await db.execute( + "DELETE FROM webpush_subscriptions WHERE endpoint = ? AND user = ?", + ( + endpoint, + user, + ), + ) + + +async def delete_webpush_subscriptions(endpoint: str) -> None: + await db.execute( + "DELETE FROM webpush_subscriptions WHERE endpoint = ?", (endpoint,) + ) diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index ba1416eaf..9cc889133 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -378,3 +378,18 @@ async def m014_set_deleted_wallets(db): # catching errors like this won't be necessary in anymore now that we # keep track of db versions so no migration ever runs twice. pass + + +async def m015_create_push_notification_subscriptions_table(db): + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS webpush_subscriptions ( + endpoint TEXT NOT NULL, + "user" TEXT NOT NULL, + data TEXT NOT NULL, + host TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + PRIMARY KEY (endpoint, "user") + ); + """ + ) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index e4c97dcc6..0e6e5ac4f 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -330,3 +330,15 @@ class CreateTopup(BaseModel): class CreateLnurlAuth(BaseModel): callback: str + + +class CreateWebPushSubscription(BaseModel): + subscription: str + + +class WebPushSubscription(BaseModel): + endpoint: str + user: str + data: str + host: str + timestamp: str diff --git a/lnbits/core/services.py b/lnbits/core/services.py index c14271219..5dab75f93 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -6,10 +6,13 @@ from typing import Dict, List, Optional, Tuple, TypedDict from urllib.parse import parse_qs, urlparse import httpx +from cryptography.hazmat.primitives import serialization from fastapi import Depends, WebSocket from lnurl import LnurlErrorResponse from lnurl import decode as decode_lnurl from loguru import logger +from py_vapid import Vapid +from py_vapid.utils import b64urlencode from lnbits import bolt11 from lnbits.db import Connection @@ -41,6 +44,7 @@ from .crud import ( get_total_balance, get_wallet, get_wallet_payment, + update_admin_settings, update_payment_details, update_payment_status, update_super_user, @@ -524,7 +528,7 @@ async def check_admin_settings(): # create new settings if table is empty logger.warning("Settings DB empty. Inserting default settings.") settings_db = await init_admin_settings(settings.super_user) - logger.warning("Initialized settings from enviroment variables.") + logger.warning("Initialized settings from environment variables.") if settings.super_user and settings.super_user != settings_db.super_user: # .env super_user overwrites DB super_user @@ -550,6 +554,29 @@ async def check_admin_settings(): ) +async def check_webpush_settings(): + if not settings.lnbits_webpush_privkey: + vapid = Vapid() + vapid.generate_keys() + privkey = vapid.private_pem() + assert vapid.public_key, "VAPID public key does not exist" + pubkey = b64urlencode( + vapid.public_key.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + ) + push_settings = { + "lnbits_webpush_privkey": privkey.decode(), + "lnbits_webpush_pubkey": pubkey, + } + update_cached_settings(push_settings) + await update_admin_settings(push_settings) + + logger.info("Initialized webpush settings with generated VAPID key pair.") + logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}") + + def update_cached_settings(sets_dict: dict): for key, value in sets_dict.items(): if key not in readonly_variables: diff --git a/lnbits/core/static/js/service-worker.js b/lnbits/core/static/js/service-worker.js index 5e2b8b6d4..d243dfdd9 100644 --- a/lnbits/core/static/js/service-worker.js +++ b/lnbits/core/static/js/service-worker.js @@ -56,3 +56,32 @@ self.addEventListener('fetch', event => { ) } }) + +// Handle and show incoming push notifications +self.addEventListener('push', function (event) { + if (!(self.Notification && self.Notification.permission === 'granted')) { + return + } + + let data = event.data.json() + const title = data.title + const body = data.body + const url = data.url + + event.waitUntil( + self.registration.showNotification(title, { + body: body, + icon: '/favicon.ico', + data: { + url: url + } + }) + ) +}) + +// User can click on the notification message to open wallet +// Installed app will open when `url_handlers` in web app manifest is supported +self.addEventListener('notificationclick', function (event) { + event.notification.close() + event.waitUntil(clients.openWindow(event.notification.data.url)) +}) diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index f2a8ce6ad..79405af47 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -10,10 +10,15 @@ from lnbits.tasks import ( create_permanent_task, create_task, register_invoice_listener, + send_push_notification, ) from . import db -from .crud import get_balance_notify, get_wallet +from .crud import ( + get_balance_notify, + get_wallet, + get_webpush_subscriptions_for_user, +) from .models import Payment from .services import get_balance_delta, send_payment_notification, switch_to_voidwallet @@ -119,6 +124,8 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue): except (httpx.ConnectError, httpx.RequestError): pass + await send_payment_push_notification(payment) + async def dispatch_api_invoice_listeners(payment: Payment): """ @@ -159,3 +166,24 @@ async def mark_webhook_sent(payment: Payment, status: int) -> None: """, (status, payment.payment_hash), ) + + +async def send_payment_push_notification(payment: Payment): + wallet = await get_wallet(payment.wallet_id) + + if wallet: + subscriptions = await get_webpush_subscriptions_for_user(wallet.user) + + amount = int(payment.amount / 1000) + + title = f"LNbits: {wallet.name}" + body = f"You just received {amount} sat{'s'[:amount^1]}!" + + if payment.memo: + body += f"\r\n{payment.memo}" + + for subscription in subscriptions: + url = ( + f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}" + ) + await send_push_notification(subscription, title, body, url) diff --git a/lnbits/core/views/admin_api.py b/lnbits/core/views/admin_api.py index f6ed18d00..1de025b6d 100644 --- a/lnbits/core/views/admin_api.py +++ b/lnbits/core/views/admin_api.py @@ -19,7 +19,7 @@ from lnbits.core.services import ( ) from lnbits.decorators import check_admin, check_super_user from lnbits.server import server_restart -from lnbits.settings import AdminSettings, EditableSettings, settings +from lnbits.settings import AdminSettings, settings from .. import core_app, core_app_extra from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings @@ -58,9 +58,7 @@ async def api_get_settings( "/admin/api/v1/settings/", status_code=HTTPStatus.OK, ) -async def api_update_settings( - data: EditableSettings, user: User = Depends(check_admin) -): +async def api_update_settings(data: dict, user: User = Depends(check_admin)): await update_admin_settings(data) admin_settings = await get_admin_settings(user.super_user) assert admin_settings, "Updated admin settings not found." diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 59ef4d5b7..fbabf1166 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -1,11 +1,12 @@ import asyncio +import base64 import hashlib import json import uuid from http import HTTPStatus from io import BytesIO from typing import Dict, List, Optional, Union -from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse +from urllib.parse import ParseResult, parse_qs, unquote, urlencode, urlparse, urlunparse import httpx import pyqrcode @@ -33,12 +34,14 @@ from lnbits.core.models import ( CreateInvoice, CreateLnurl, CreateLnurlAuth, + CreateWebPushSubscription, DecodePayment, Payment, PaymentFilters, User, Wallet, WalletType, + WebPushSubscription, ) from lnbits.db import Filters, Page from lnbits.decorators import ( @@ -69,9 +72,11 @@ from .. import core_app, core_app_extra, db from ..crud import ( add_installed_extension, create_tinyurl, + create_webpush_subscription, delete_dbversion, delete_installed_extension, delete_tinyurl, + delete_webpush_subscription, drop_extension_db, get_dbversions, get_payments, @@ -80,6 +85,7 @@ from ..crud import ( get_tinyurl, get_tinyurl_by_url, get_wallet_for_key, + get_webpush_subscription, save_balance_check, update_wallet, ) @@ -986,3 +992,39 @@ async def api_tinyurl(tinyurl_id: str): raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl" ) + + +############################WEBPUSH################################## + + +@core_app.post("/api/v1/webpush", status_code=HTTPStatus.CREATED) +async def api_create_webpush_subscription( + request: Request, + data: CreateWebPushSubscription, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> WebPushSubscription: + subscription = json.loads(data.subscription) + endpoint = subscription["endpoint"] + host = urlparse(str(request.url)).netloc + + subscription = await get_webpush_subscription(endpoint, wallet.wallet.user) + if subscription: + return subscription + else: + return await create_webpush_subscription( + endpoint, + wallet.wallet.user, + data.subscription, + host, + ) + + +@core_app.delete("/api/v1/webpush", status_code=HTTPStatus.OK) +async def api_delete_webpush_subscription( + request: Request, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + endpoint = unquote( + base64.b64decode(request.query_params.get("endpoint")).decode("utf-8") + ) + await delete_webpush_subscription(endpoint, wallet.wallet.user) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index f07b10ad8..630158f47 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -1,6 +1,7 @@ import asyncio from http import HTTPStatus from typing import List, Optional +from urllib.parse import urlparse from fastapi import Depends, Query, Request, status from fastapi.exceptions import HTTPException @@ -359,7 +360,9 @@ async def service_worker(): @core_html_routes.get("/manifest/{usr}.webmanifest") -async def manifest(usr: str): +async def manifest(request: Request, usr: str): + host = urlparse(str(request.url)).netloc + user = await get_user(usr) if not user: raise HTTPException(status_code=HTTPStatus.NOT_FOUND) @@ -393,6 +396,7 @@ async def manifest(usr: str): } for wallet in user.wallets ], + "url_handlers": [{"origin": f"https://{host}"}], } diff --git a/lnbits/helpers.py b/lnbits/helpers.py index 59252d795..c4f639158 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -65,6 +65,8 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa t.env.globals["INCLUDED_JS"] = vendor_files["js"] t.env.globals["INCLUDED_CSS"] = vendor_files["css"] + t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey + return t diff --git a/lnbits/settings.py b/lnbits/settings.py index 0a12c8364..9967efc20 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -228,6 +228,11 @@ class FundingSourcesSettings( lnbits_backend_wallet_class: str = Field(default="VoidWallet") +class WebPushSettings(LNbitsSettings): + lnbits_webpush_pubkey: str = Field(default=None) + lnbits_webpush_privkey: str = Field(default=None) + + class EditableSettings( UsersSettings, ExtensionsSettings, @@ -237,6 +242,7 @@ class EditableSettings( FundingSourcesSettings, BoltzExtensionSettings, LightningSettings, + WebPushSettings, ): @validator( "lnbits_admin_users", diff --git a/lnbits/static/js/components.js b/lnbits/static/js/components.js index 15865a20d..fc098ef71 100644 --- a/lnbits/static/js/components.js +++ b/lnbits/static/js/components.js @@ -215,38 +215,38 @@ Vue.component('lnbits-payment-details', { }, template: `