[FEAT] Push notification integration into core (#1393)

* push notification integration into core

added missing component

fixed bell working on all pages
- made pubkey global template env var
- had to move `get_push_notification_pubkey` to `helpers.py` because of circular reference with `tasks.py`

formay

trying to fix mypy

added py-vapid to requirements

Trying to fix stub mypy issue

* removed key files

* webpush key pair is saved in db `webpush_settings`

* removed lnaddress extension changes

* support for multi user account subscriptions, subscriptions are stored user based

fixed syntax error

fixed syntax error

removed unused line

* fixed subscribed user storage with local storage, no get request required

* method is singular now

* cleanup unsubscribed or expired push subscriptions

fixed flake8 errors

fixed poetry errors

* updating to latest lnbits

formatting, rebase error

fix

* remove unused?

* revert

* relock

* remove

* do not create settings table use adminsettings

mypy

fix

* cleanup old code

* catch case when client tries to recreate existing webpush subscription e.g. on cleared local storage

* show notification bell on user related pages only

* use local storage with one key like array, some refactoring

* fixed crud import

* fixed too long line

* removed unused imports

* ruff

* make webpush editable

* fixed privkey encoding

* fix ruff

* fix migration

---------

Co-authored-by: schneimi <admin@schneimi.de>
Co-authored-by: schneimi <dev@schneimi.de>
Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
schneimi 2023-09-11 15:48:49 +02:00 committed by GitHub
parent 8f0c1f6a80
commit fb98576431
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 571 additions and 21 deletions

View file

@ -39,7 +39,7 @@ from .core import (
core_app_extra, core_app_extra,
update_installed_extension_state, 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 .core.views.generic import core_html_routes
from .extension_manager import Extension, InstallableExtension, get_valid_extensions from .extension_manager import Extension, InstallableExtension, get_valid_extensions
from .helpers import template_renderer from .helpers import template_renderer
@ -332,6 +332,7 @@ def register_startup(app: FastAPI):
# setup admin settings # setup admin settings
await check_admin_settings() await check_admin_settings()
await check_webpush_settings()
log_server_info() log_server_info()

View file

@ -10,10 +10,23 @@ from lnbits import bolt11
from lnbits.core.models import WalletType from lnbits.core.models import WalletType
from lnbits.db import Connection, Database, Filters, Page from lnbits.db import Connection, Database, Filters, Page
from lnbits.extension_manager import InstallableExtension 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 . import db
from .models import BalanceCheck, Payment, PaymentFilters, TinyURL, User, Wallet from .models import (
BalanceCheck,
Payment,
PaymentFilters,
TinyURL,
User,
Wallet,
WebPushSubscription,
)
# accounts # accounts
# -------- # --------
@ -788,8 +801,16 @@ async def delete_admin_settings():
await db.execute("DELETE FROM settings") await db.execute("DELETE FROM settings")
async def update_admin_settings(data: EditableSettings): async def update_admin_settings(data: dict):
await db.execute("UPDATE settings SET editable_settings = ?", (json.dumps(data),)) 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: 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 = ?", "DELETE FROM tiny_url WHERE id = ?",
(tinyurl_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,)
)

View file

@ -378,3 +378,18 @@ async def m014_set_deleted_wallets(db):
# catching errors like this won't be necessary in anymore now that we # catching errors like this won't be necessary in anymore now that we
# keep track of db versions so no migration ever runs twice. # keep track of db versions so no migration ever runs twice.
pass 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")
);
"""
)

View file

@ -330,3 +330,15 @@ class CreateTopup(BaseModel):
class CreateLnurlAuth(BaseModel): class CreateLnurlAuth(BaseModel):
callback: str callback: str
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: str

View file

@ -6,10 +6,13 @@ from typing import Dict, List, Optional, Tuple, TypedDict
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import httpx import httpx
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket from fastapi import Depends, WebSocket
from lnurl import LnurlErrorResponse from lnurl import LnurlErrorResponse
from lnurl import decode as decode_lnurl from lnurl import decode as decode_lnurl
from loguru import logger from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits import bolt11 from lnbits import bolt11
from lnbits.db import Connection from lnbits.db import Connection
@ -41,6 +44,7 @@ from .crud import (
get_total_balance, get_total_balance,
get_wallet, get_wallet,
get_wallet_payment, get_wallet_payment,
update_admin_settings,
update_payment_details, update_payment_details,
update_payment_status, update_payment_status,
update_super_user, update_super_user,
@ -524,7 +528,7 @@ async def check_admin_settings():
# create new settings if table is empty # create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.") logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user) 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: if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites 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): def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items(): for key, value in sets_dict.items():
if key not in readonly_variables: if key not in readonly_variables:

View file

@ -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))
})

View file

@ -10,10 +10,15 @@ from lnbits.tasks import (
create_permanent_task, create_permanent_task,
create_task, create_task,
register_invoice_listener, register_invoice_listener,
send_push_notification,
) )
from . import db 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 .models import Payment
from .services import get_balance_delta, send_payment_notification, switch_to_voidwallet 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): except (httpx.ConnectError, httpx.RequestError):
pass pass
await send_payment_push_notification(payment)
async def dispatch_api_invoice_listeners(payment: 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), (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)

View file

@ -19,7 +19,7 @@ from lnbits.core.services import (
) )
from lnbits.decorators import check_admin, check_super_user from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart 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 .. import core_app, core_app_extra
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings 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/", "/admin/api/v1/settings/",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
) )
async def api_update_settings( async def api_update_settings(data: dict, user: User = Depends(check_admin)):
data: EditableSettings, user: User = Depends(check_admin)
):
await update_admin_settings(data) await update_admin_settings(data)
admin_settings = await get_admin_settings(user.super_user) admin_settings = await get_admin_settings(user.super_user)
assert admin_settings, "Updated admin settings not found." assert admin_settings, "Updated admin settings not found."

View file

@ -1,11 +1,12 @@
import asyncio import asyncio
import base64
import hashlib import hashlib
import json import json
import uuid import uuid
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
from typing import Dict, List, Optional, Union 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 httpx
import pyqrcode import pyqrcode
@ -33,12 +34,14 @@ from lnbits.core.models import (
CreateInvoice, CreateInvoice,
CreateLnurl, CreateLnurl,
CreateLnurlAuth, CreateLnurlAuth,
CreateWebPushSubscription,
DecodePayment, DecodePayment,
Payment, Payment,
PaymentFilters, PaymentFilters,
User, User,
Wallet, Wallet,
WalletType, WalletType,
WebPushSubscription,
) )
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
@ -69,9 +72,11 @@ from .. import core_app, core_app_extra, db
from ..crud import ( from ..crud import (
add_installed_extension, add_installed_extension,
create_tinyurl, create_tinyurl,
create_webpush_subscription,
delete_dbversion, delete_dbversion,
delete_installed_extension, delete_installed_extension,
delete_tinyurl, delete_tinyurl,
delete_webpush_subscription,
drop_extension_db, drop_extension_db,
get_dbversions, get_dbversions,
get_payments, get_payments,
@ -80,6 +85,7 @@ from ..crud import (
get_tinyurl, get_tinyurl,
get_tinyurl_by_url, get_tinyurl_by_url,
get_wallet_for_key, get_wallet_for_key,
get_webpush_subscription,
save_balance_check, save_balance_check,
update_wallet, update_wallet,
) )
@ -986,3 +992,39 @@ async def api_tinyurl(tinyurl_id: str):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl" 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)

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import List, Optional
from urllib.parse import urlparse
from fastapi import Depends, Query, Request, status from fastapi import Depends, Query, Request, status
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
@ -359,7 +360,9 @@ async def service_worker():
@core_html_routes.get("/manifest/{usr}.webmanifest") @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) user = await get_user(usr)
if not user: if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND) raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
@ -393,6 +396,7 @@ async def manifest(usr: str):
} }
for wallet in user.wallets for wallet in user.wallets
], ],
"url_handlers": [{"origin": f"https://{host}"}],
} }

View file

@ -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_JS"] = vendor_files["js"]
t.env.globals["INCLUDED_CSS"] = vendor_files["css"] t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
return t return t

View file

@ -228,6 +228,11 @@ class FundingSourcesSettings(
lnbits_backend_wallet_class: str = Field(default="VoidWallet") 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( class EditableSettings(
UsersSettings, UsersSettings,
ExtensionsSettings, ExtensionsSettings,
@ -237,6 +242,7 @@ class EditableSettings(
FundingSourcesSettings, FundingSourcesSettings,
BoltzExtensionSettings, BoltzExtensionSettings,
LightningSettings, LightningSettings,
WebPushSettings,
): ):
@validator( @validator(
"lnbits_admin_users", "lnbits_admin_users",

View file

@ -346,3 +346,216 @@ Vue.component('lnbits-lnurlpay-success-action', {
) )
} }
}) })
Vue.component('lnbits-notifications-btn', {
mixins: [windowMixin],
props: ['pubkey'],
data() {
return {
isSupported: false,
isSubscribed: false,
isPermissionGranted: false,
isPermissionDenied: false
}
},
template: `
<q-btn
v-if="g.user.wallets"
:disabled="!this.isSupported"
dense
flat
round
@click="toggleNotifications()"
:icon="this.isSubscribed ? 'notifications_active' : 'notifications_off'"
size="sm"
type="a"
>
<q-tooltip v-if="this.isSupported && !this.isSubscribed">Subscribe to notifications</q-tooltip>
<q-tooltip v-if="this.isSupported && this.isSubscribed">Unsubscribe from notifications</q-tooltip>
<q-tooltip v-if="this.isSupported && this.isPermissionDenied">
Notifications are disabled,<br/>please enable or reset permissions
</q-tooltip>
<q-tooltip v-if="!this.isSupported">Notifications are not supported</q-tooltip>
</q-btn>
`,
methods: {
// converts base64 to Array buffer
urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/')
const rawData = atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
},
toggleNotifications() {
this.isSubscribed ? this.unsubscribe() : this.subscribe()
},
saveUserSubscribed(user) {
let subscribedUsers =
JSON.parse(
this.$q.localStorage.getItem('lnbits.webpush.subscribedUsers')
) || []
if (!subscribedUsers.includes(user)) subscribedUsers.push(user)
this.$q.localStorage.set(
'lnbits.webpush.subscribedUsers',
JSON.stringify(subscribedUsers)
)
},
removeUserSubscribed(user) {
let subscribedUsers =
JSON.parse(
this.$q.localStorage.getItem('lnbits.webpush.subscribedUsers')
) || []
subscribedUsers = subscribedUsers.filter(arr => arr !== user)
this.$q.localStorage.set(
'lnbits.webpush.subscribedUsers',
JSON.stringify(subscribedUsers)
)
},
isUserSubscribed(user) {
let subscribedUsers =
JSON.parse(
this.$q.localStorage.getItem('lnbits.webpush.subscribedUsers')
) || []
return subscribedUsers.includes(user)
},
subscribe() {
var self = this
// catch clicks from disabled type='a' button (https://github.com/quasarframework/quasar/issues/9258)
if (!this.isSupported || this.isPermissionDenied) {
return
}
// ask for notification permission
Notification.requestPermission()
.then(permission => {
this.isPermissionGranted = permission === 'granted'
this.isPermissionDenied = permission === 'denied'
})
.catch(function (e) {
console.log(e)
})
// create push subscription
navigator.serviceWorker.ready.then(registration => {
navigator.serviceWorker.getRegistration().then(registration => {
registration.pushManager
.getSubscription()
.then(function (subscription) {
if (
subscription === null ||
!self.isUserSubscribed(self.g.user.id)
) {
const applicationServerKey = self.urlB64ToUint8Array(
self.pubkey
)
const options = {applicationServerKey, userVisibleOnly: true}
registration.pushManager
.subscribe(options)
.then(function (subscription) {
LNbits.api
.request(
'POST',
'/api/v1/webpush',
self.g.user.wallets[0].adminkey,
{
subscription: JSON.stringify(subscription)
}
)
.then(function (response) {
self.saveUserSubscribed(response.data.user)
self.isSubscribed = true
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
}
})
.catch(function (e) {
console.log(e)
})
})
})
},
unsubscribe() {
var self = this
navigator.serviceWorker.ready
.then(registration => {
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
LNbits.api
.request(
'DELETE',
'/api/v1/webpush?endpoint=' + btoa(subscription.endpoint),
self.g.user.wallets[0].adminkey
)
.then(function () {
self.removeUserSubscribed(self.g.user.id)
self.isSubscribed = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}
})
})
.catch(function (e) {
console.log(e)
})
},
checkSupported: function () {
let https = window.location.protocol === 'https:'
let serviceWorkerApi = 'serviceWorker' in navigator
let notificationApi = 'Notification' in window
let pushApi = 'PushManager' in window
this.isSupported = https && serviceWorkerApi && notificationApi && pushApi
if (!this.isSupported) {
console.log(
'Notifications disabled because requirements are not met:',
{
HTTPS: https,
'Service Worker API': serviceWorkerApi,
'Notification API': notificationApi,
'Push API': pushApi
}
)
}
return this.isSupported
},
updateSubscriptionStatus: async function () {
var self = this
await navigator.serviceWorker.ready
.then(registration => {
registration.pushManager.getSubscription().then(subscription => {
self.isSubscribed =
!!subscription && self.isUserSubscribed(self.g.user.id)
})
})
.catch(function (e) {
console.log(e)
})
}
},
created: function () {
this.isPermissionDenied = Notification.permission === 'denied'
if (this.checkSupported()) {
this.updateSubscriptionStatus()
}
}
})

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import json
import time import time
import traceback import traceback
import uuid import uuid
@ -7,14 +8,18 @@ from typing import Dict, List, Optional
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from loguru import logger from loguru import logger
from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import ( from lnbits.core.crud import (
delete_expired_invoices, delete_expired_invoices,
delete_webpush_subscriptions,
get_balance_checks, get_balance_checks,
get_payments, get_payments,
get_standalone_payment, get_standalone_payment,
) )
from lnbits.core.services import redeem_lnurl_withdraw from lnbits.core.services import redeem_lnurl_withdraw
from lnbits.settings import settings
from lnbits.wallets import get_wallet_class from lnbits.wallets import get_wallet_class
from .core import db from .core import db
@ -204,3 +209,21 @@ async def invoice_callback_dispatcher(checking_id: str):
for chan_name, send_chan in invoice_listeners.items(): for chan_name, send_chan in invoice_listeners.items():
logger.trace(f"sse sending to chan: {chan_name}") logger.trace(f"sse sending to chan: {chan_name}")
await send_chan.put(payment) await send_chan.put(payment)
async def send_push_notification(subscription, title, body, url=""):
vapid = Vapid()
try:
logger.debug("sending push notification")
webpush(
json.loads(subscription.data),
json.dumps({"title": title, "body": body, "url": url}),
vapid.from_pem(bytes(settings.lnbits_webpush_privkey, "utf-8")),
{"aud": "", "sub": "mailto:alan@lnbits.com"},
)
except WebPushException as e:
if e.response.status_code == HTTPStatus.GONE:
# cleanup unsubscribed or expired push subscriptions
await delete_webpush_subscriptions(subscription.endpoint)
else:
logger.error(f"failed sending push notification: {e.response.text}")

View file

@ -70,6 +70,10 @@
> >
<span>OFFLINE</span> <span>OFFLINE</span>
</q-badge> </q-badge>
<lnbits-notifications-btn
v-if="g.user"
pubkey="{{ WEBPUSH_PUBKEY }}"
></lnbits-notifications-btn>
<q-btn-dropdown <q-btn-dropdown
dense dense
flat flat

45
poetry.lock generated
View file

@ -703,6 +703,19 @@ files = [
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
] ]
[[package]]
name = "http-ece"
version = "1.1.0"
description = "Encrypted Content Encoding for HTTP"
optional = false
python-versions = "*"
files = [
{file = "http_ece-1.1.0.tar.gz", hash = "sha256:932ebc2fa7c216954c320a188ae9c1f04d01e67bec9cdce1bfbc912813b0b4f8"},
]
[package.dependencies]
cryptography = ">=2.5"
[[package]] [[package]]
name = "httpcore" name = "httpcore"
version = "0.15.0" version = "0.15.0"
@ -1329,6 +1342,19 @@ files = [
{file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"}, {file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"},
] ]
[[package]]
name = "py-vapid"
version = "1.9.0"
description = "Simple VAPID header generation library"
optional = false
python-versions = "*"
files = [
{file = "py-vapid-1.9.0.tar.gz", hash = "sha256:0664ab7899742ef2b287397a4d461ef691ed0cc2f587205128d8cf617ffdb919"},
]
[package.dependencies]
cryptography = ">=2.5"
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.21" version = "2.21"
@ -1615,6 +1641,23 @@ files = [
[package.extras] [package.extras]
cli = ["click (>=5.0)"] cli = ["click (>=5.0)"]
[[package]]
name = "pywebpush"
version = "1.14.0"
description = "WebPush publication library"
optional = false
python-versions = "*"
files = [
{file = "pywebpush-1.14.0.tar.gz", hash = "sha256:6c36e1679268219e693ba940db2bf254c240ca02664de102b7269afc3c545731"},
]
[package.dependencies]
cryptography = ">=2.6.1"
http-ece = ">=1.1.0"
py-vapid = ">=1.7.0"
requests = ">=2.21.0"
six = ">=1.15.0"
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.1" version = "6.0.1"
@ -2272,4 +2315,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10 | ^3.9" python-versions = "^3.10 | ^3.9"
content-hash = "3b1b73b1df182fae17e692b1ebd6d35a8791ca62df3ece145b05ae7f4465ae16" content-hash = "d294784e932335e91b4d096f406664b1240d2e20a7820060524bd6ef651d30ec"

View file

@ -34,6 +34,7 @@ async-timeout = "4.0.2"
pyln-client = "23.8" pyln-client = "23.8"
cashu = "0.9.0" cashu = "0.9.0"
slowapi = "^0.1.7" slowapi = "^0.1.7"
pywebpush = "^1.14.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^7.1.2" pytest = "^7.1.2"
@ -91,6 +92,8 @@ module = [
"psycopg2.*", "psycopg2.*",
"pyngrok.*", "pyngrok.*",
"pyln.client.*", "pyln.client.*",
"py_vapid.*",
"pywebpush.*",
] ]
ignore_missing_imports = "True" ignore_missing_imports = "True"