mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-24 06:48:02 +01:00
[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:
parent
8f0c1f6a80
commit
fb98576431
17 changed files with 571 additions and 21 deletions
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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,)
|
||||||
|
)
|
||||||
|
|
|
@ -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")
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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))
|
||||||
|
})
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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}"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
|
@ -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
45
poetry.lock
generated
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue