feat: update lnurl lib refactor lnurl endpoints (#3271)
This commit is contained in:
@@ -11,6 +11,7 @@ from .views.fiat_api import fiat_router
|
||||
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router
|
||||
from .views.lnurl_api import lnurl_router
|
||||
from .views.node_api import node_router, public_node_router, super_node_router
|
||||
from .views.payment_api import payment_router
|
||||
from .views.tinyurl_api import tinyurl_router
|
||||
@@ -42,6 +43,7 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(users_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(fiat_router)
|
||||
app.include_router(lnurl_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from .audit import AuditEntry, AuditFilters
|
||||
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
|
||||
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
|
||||
from .misc import (
|
||||
BalanceDelta,
|
||||
Callback,
|
||||
@@ -63,8 +63,8 @@ __all__ = [
|
||||
"ConversionData",
|
||||
"CoreAppExtra",
|
||||
"CreateInvoice",
|
||||
"CreateLnurl",
|
||||
"CreateLnurlAuth",
|
||||
"CreateLnurlPayment",
|
||||
"CreateLnurlWithdraw",
|
||||
"CreatePayment",
|
||||
"CreateUser",
|
||||
"CreateWallet",
|
||||
@@ -75,7 +75,6 @@ __all__ = [
|
||||
"LoginUsernamePassword",
|
||||
"LoginUsr",
|
||||
"PayInvoice",
|
||||
"PayLnurlWData",
|
||||
"Payment",
|
||||
"PaymentCountField",
|
||||
"PaymentCountStat",
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
from typing import Optional
|
||||
|
||||
from lnurl import Lnurl, LnurlPayResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateLnurl(BaseModel):
|
||||
description_hash: str
|
||||
callback: str
|
||||
class CreateLnurlPayment(BaseModel):
|
||||
res: LnurlPayResponse
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
internal_memo: Optional[str] = None
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
callback: str
|
||||
|
||||
|
||||
class PayLnurlWData(BaseModel):
|
||||
lnurl_w: str
|
||||
class CreateLnurlWithdraw(BaseModel):
|
||||
lnurl_w: Lnurl
|
||||
|
||||
@@ -5,6 +5,7 @@ from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import Query
|
||||
from lnurl import LnurlWithdrawResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
@@ -262,7 +263,7 @@ class CreateInvoice(BaseModel):
|
||||
extra: dict | None = None
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_callback: str | None = None
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
fiat_provider: str | None = None
|
||||
|
||||
@validator("payment_hash")
|
||||
|
||||
@@ -2,7 +2,7 @@ from .funding_source import (
|
||||
get_balance_delta,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
|
||||
from .lnurl import fetch_lnurl_pay_request
|
||||
from .notifications import enqueue_notification, send_payment_notification
|
||||
from .payments import (
|
||||
calculate_fiat_amounts,
|
||||
@@ -52,11 +52,10 @@ __all__ = [
|
||||
"enqueue_notification",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"fetch_lnurl_pay_request",
|
||||
"get_balance_delta",
|
||||
"get_payments_daily_stats",
|
||||
"pay_invoice",
|
||||
"perform_lnurlauth",
|
||||
"redeem_lnurl_withdraw",
|
||||
"send_payment_notification",
|
||||
"service_fee",
|
||||
"settle_hold_invoice",
|
||||
|
||||
+22
-152
@@ -1,159 +1,29 @@
|
||||
import asyncio
|
||||
import json
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from lnurl import LnurlPayActionResponse
|
||||
from lnurl import execute_pay_request as lnurlp
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url, url_for
|
||||
from lnbits.lnurl import LnurlErrorResponse
|
||||
from lnbits.lnurl import decode as decode_lnurl
|
||||
from lnbits.core.models import CreateLnurlPayment
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .payments import create_invoice
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
lnurl_request: str,
|
||||
memo: Optional[str] = None,
|
||||
extra: Optional[dict] = None,
|
||||
wait_seconds: int = 0,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
if not lnurl_request:
|
||||
return None
|
||||
async def fetch_lnurl_pay_request(data: CreateLnurlPayment) -> LnurlPayActionResponse:
|
||||
"""
|
||||
Pay an LNURL payment request.
|
||||
|
||||
res = {}
|
||||
raises `LnurlResponseException` if pay request fails
|
||||
"""
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
lnurl = decode_lnurl(lnurl_request)
|
||||
check_callback_url(str(lnurl))
|
||||
r = await client.get(str(lnurl))
|
||||
res = r.json()
|
||||
if data.unit and data.unit != "sat":
|
||||
# shift to float with 2 decimal places
|
||||
amount = round(data.amount / 1000, 2)
|
||||
amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
|
||||
amount_msat *= 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
|
||||
try:
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=int(res["maxWithdrawable"] / 1000),
|
||||
memo=memo or res["defaultDescription"] or "",
|
||||
extra=extra,
|
||||
conn=conn,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw "
|
||||
f"from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
|
||||
if wait_seconds:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
params = {"k1": res["k1"], "pr": payment_request}
|
||||
|
||||
try:
|
||||
params["balanceNotify"] = url_for(
|
||||
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
|
||||
external=True,
|
||||
wal=wallet_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(res["callback"])
|
||||
await client.get(res["callback"], params=params)
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
callback: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
|
||||
|
||||
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||
"""for strict DER we need to encode the integer with some quirks"""
|
||||
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
|
||||
|
||||
if len(b) == 0:
|
||||
# ensure there's at least one byte when the int is zero
|
||||
return bytes([0])
|
||||
|
||||
if b[0] & 0x80 != 0:
|
||||
# ensure it doesn't start with a 0x80 and so it isn't
|
||||
# interpreted as a negative number
|
||||
return bytes([0]) + b
|
||||
|
||||
return b
|
||||
|
||||
def encode_strict_der(r: int, s: int, order: int):
|
||||
# if s > order/2 verification will fail sometimes
|
||||
# so we must fix it here see:
|
||||
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
|
||||
if s > order // 2:
|
||||
s = order - s
|
||||
|
||||
# now we do the strict DER encoding copied from
|
||||
# https://github.com/KiriKiri/bip66 (without any checks)
|
||||
r_temp = int_to_bytes_suitable_der(r)
|
||||
s_temp = int_to_bytes_suitable_der(s)
|
||||
|
||||
r_len = len(r_temp)
|
||||
s_len = len(s_temp)
|
||||
sign_len = 6 + r_len + s_len
|
||||
|
||||
signature = BytesIO()
|
||||
signature.write(0x30.to_bytes(1, "big", signed=False))
|
||||
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_temp)
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_temp)
|
||||
|
||||
return signature.getvalue()
|
||||
|
||||
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
if not key.verifying_key:
|
||||
raise ValueError("LNURLauth verifying_key does not exist")
|
||||
check_callback_url(callback)
|
||||
r = await client.get(
|
||||
callback,
|
||||
params={
|
||||
"k1": k1.hex(),
|
||||
"key": key.verifying_key.to_string("compressed").hex(),
|
||||
"sig": sig.hex(),
|
||||
},
|
||||
)
|
||||
try:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] == "OK":
|
||||
return None
|
||||
|
||||
return LnurlErrorResponse(reason=resp["reason"])
|
||||
except (KeyError, json.decoder.JSONDecodeError):
|
||||
return LnurlErrorResponse(
|
||||
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
|
||||
)
|
||||
return await lnurlp(
|
||||
data.res,
|
||||
msat=str(amount_msat),
|
||||
user_agent=settings.user_agent,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from bolt11 import encode as bolt11_encode
|
||||
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
|
||||
from lnurl import execute_withdraw as lnurl_withdraw
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.payments import get_daily_stats
|
||||
@@ -172,8 +172,8 @@ async def create_fiat_invoice(
|
||||
|
||||
|
||||
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
description_hash = None
|
||||
unhashed_description = None
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
if data.description_hash:
|
||||
@@ -207,28 +207,26 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
payment_hash=data.payment_hash,
|
||||
)
|
||||
|
||||
# lnurl_response is not saved in the database
|
||||
if data.lnurl_callback:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(data.lnurl_callback)
|
||||
r = await client.get(
|
||||
data.lnurl_callback,
|
||||
params={"pr": payment.bolt11},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
payment.extra["lnurl_response"] = r.text
|
||||
else:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] != "OK":
|
||||
payment.extra["lnurl_response"] = resp["reason"]
|
||||
else:
|
||||
payment.extra["lnurl_response"] = True
|
||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||
logger.error(ex)
|
||||
payment.extra["lnurl_response"] = False
|
||||
if data.lnurl_withdraw:
|
||||
try:
|
||||
check_callback_url(data.lnurl_withdraw.callback)
|
||||
res = await lnurl_withdraw(
|
||||
data.lnurl_withdraw,
|
||||
payment.bolt11,
|
||||
user_agent=settings.user_agent,
|
||||
timeout=10,
|
||||
)
|
||||
if isinstance(res, LnurlErrorResponse):
|
||||
payment.extra["lnurl_response"] = res.reason
|
||||
payment.status = "failed"
|
||||
elif isinstance(res, LnurlSuccessResponse):
|
||||
payment.extra["lnurl_response"] = True
|
||||
payment.status = "success"
|
||||
except Exception as exc:
|
||||
payment.extra["lnurl_response"] = str(exc)
|
||||
payment.status = "failed"
|
||||
# updating to payment here would run into a race condition
|
||||
# with the payment listeners and they will overwrite each other
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
+3
-157
@@ -1,36 +1,20 @@
|
||||
import hashlib
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from time import time
|
||||
from typing import Any
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from lnbits.core.models import (
|
||||
BaseWallet,
|
||||
ConversionData,
|
||||
CreateLnurlAuth,
|
||||
CreateWallet,
|
||||
User,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_user_exists,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
allowed_currencies,
|
||||
@@ -41,7 +25,7 @@ from lnbits.utils.exchange_rates import (
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import StatusResponse
|
||||
|
||||
from ..services import create_user_account, perform_lnurlauth
|
||||
from ..services import create_user_account
|
||||
|
||||
api_router = APIRouter(tags=["Core"])
|
||||
|
||||
@@ -92,144 +76,6 @@ async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
return user.wallets[0]
|
||||
|
||||
|
||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan( # noqa: C901
|
||||
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
try:
|
||||
url = str(lnurl_decode(code))
|
||||
domain = urlparse(url).netloc
|
||||
except Exception as exc:
|
||||
# parse internet identifier (user@domain.com)
|
||||
name_domain = code.split("@")
|
||||
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
|
||||
name, domain = name_domain
|
||||
url = (
|
||||
("http://" if domain.endswith(".onion") else "https://")
|
||||
+ domain
|
||||
+ "/.well-known/lnurlp/"
|
||||
+ name
|
||||
)
|
||||
# will proceed with these values
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
|
||||
) from exc
|
||||
|
||||
# params is what will be returned to the client
|
||||
params: dict = {"domain": domain}
|
||||
|
||||
if "tag=login" in url:
|
||||
params.update(kind="auth")
|
||||
params.update(callback=url) # with k1 already in it
|
||||
|
||||
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
|
||||
if not lnurlauth_key.verifying_key:
|
||||
raise ValueError("LNURL auth key not found for this domain.")
|
||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||
else:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
check_callback_url(url)
|
||||
try:
|
||||
r = await client.get(url, timeout=5)
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
|
||||
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"domain": domain,
|
||||
"message": "failed to get parameters",
|
||||
},
|
||||
) from exc
|
||||
try:
|
||||
data = json.loads(r.text)
|
||||
except json.decoder.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"domain": domain,
|
||||
"message": f"got invalid response '{r.text[:200]}'",
|
||||
},
|
||||
) from exc
|
||||
|
||||
try:
|
||||
tag: str = data.get("tag")
|
||||
params.update(**data)
|
||||
if tag == "channelRequest":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail={
|
||||
"domain": domain,
|
||||
"kind": "channel",
|
||||
"message": "unsupported",
|
||||
},
|
||||
)
|
||||
elif tag == "withdrawRequest":
|
||||
params.update(kind="withdraw")
|
||||
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
||||
|
||||
# callback with k1 already in it
|
||||
parsed_callback: ParseResult = urlparse(data["callback"])
|
||||
qs: dict = parse_qs(parsed_callback.query)
|
||||
qs["k1"] = data["k1"]
|
||||
|
||||
# balanceCheck/balanceNotify
|
||||
if "balanceCheck" in data:
|
||||
params.update(balanceCheck=data["balanceCheck"])
|
||||
|
||||
# format callback url and send to client
|
||||
parsed_callback = parsed_callback._replace(
|
||||
query=urlencode(qs, doseq=True)
|
||||
)
|
||||
params.update(callback=urlunparse(parsed_callback))
|
||||
elif tag == "payRequest":
|
||||
params.update(kind="pay")
|
||||
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
||||
|
||||
params.update(
|
||||
description_hash=hashlib.sha256(
|
||||
data["metadata"].encode()
|
||||
).hexdigest()
|
||||
)
|
||||
metadata = json.loads(data["metadata"])
|
||||
for [k, v] in metadata:
|
||||
if k == "text/plain":
|
||||
params.update(description=v)
|
||||
if k in ("image/jpeg;base64", "image/png;base64"):
|
||||
data_uri = f"data:{k},{v}"
|
||||
params.update(image=data_uri)
|
||||
if k in ("text/email", "text/identifier"):
|
||||
params.update(targetUser=v)
|
||||
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"domain": domain,
|
||||
"message": f"lnurl JSON response invalid: {exc}",
|
||||
},
|
||||
) from exc
|
||||
|
||||
return params
|
||||
|
||||
|
||||
@api_router.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
err = await perform_lnurlauth(data.callback, wallet=wallet)
|
||||
if err:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/rate/history",
|
||||
dependencies=[Depends(check_user_exists)],
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from lnurl import (
|
||||
LnurlResponseException,
|
||||
LnurlSuccessResponse,
|
||||
)
|
||||
from lnurl import execute_login as lnurlauth
|
||||
from lnurl import execute_withdraw as lnurl_withdraw
|
||||
from lnurl import handle as lnurl_handle
|
||||
from lnurl.models import (
|
||||
LnurlAuthResponse,
|
||||
LnurlErrorResponse,
|
||||
LnurlPayResponse,
|
||||
LnurlResponseModel,
|
||||
LnurlWithdrawResponse,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import CreateLnurlWithdraw, Payment
|
||||
from lnbits.core.models.lnurl import CreateLnurlPayment
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..services import fetch_lnurl_pay_request, pay_invoice
|
||||
|
||||
lnurl_router = APIRouter(tags=["LNURL"])
|
||||
|
||||
|
||||
@lnurl_router.get(
|
||||
"/api/v1/lnurlscan/{code}",
|
||||
dependencies=[Depends(require_invoice_key)],
|
||||
response_model=LnurlPayResponse
|
||||
| LnurlWithdrawResponse
|
||||
| LnurlAuthResponse
|
||||
| LnurlErrorResponse,
|
||||
)
|
||||
async def api_lnurlscan(code: str) -> LnurlResponseModel:
|
||||
try:
|
||||
res = await lnurl_handle(code, user_agent=settings.user_agent, timeout=5)
|
||||
except LnurlResponseException as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
if isinstance(res, (LnurlPayResponse, LnurlWithdrawResponse, LnurlAuthResponse)):
|
||||
check_callback_url(res.callback)
|
||||
return res
|
||||
|
||||
|
||||
@lnurl_router.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> LnurlResponseModel:
|
||||
check_callback_url(data.callback)
|
||||
try:
|
||||
res = await lnurlauth(
|
||||
res=data,
|
||||
seed=key_type.wallet.adminkey,
|
||||
user_agent=settings.user_agent,
|
||||
timeout=5,
|
||||
)
|
||||
return res
|
||||
except LnurlResponseException as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@lnurl_router.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Payment:
|
||||
try:
|
||||
res = await fetch_lnurl_pay_request(data=data)
|
||||
except LnurlResponseException as exc:
|
||||
logger.warning(exc)
|
||||
msg = f"Failed to connect to {data.res.callback}."
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) from exc
|
||||
|
||||
extra: dict[str, Any] = {}
|
||||
if res.success_action:
|
||||
extra["success_action"] = res.success_action.json()
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=str(res.pr),
|
||||
description=data.res.metadata.text,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
@lnurl_router.post(
|
||||
"/api/v1/payments/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK
|
||||
)
|
||||
async def api_payment_pay_with_nfc(
|
||||
payment_request: str,
|
||||
lnurl_data: CreateLnurlWithdraw,
|
||||
) -> LnurlErrorResponse | LnurlSuccessResponse:
|
||||
try:
|
||||
res = await lnurl_handle(
|
||||
lnurl_data.lnurl_w.callback_url, user_agent=settings.user_agent, timeout=10
|
||||
)
|
||||
except (LnurlResponseException, Exception) as exc:
|
||||
logger.warning(exc)
|
||||
return LnurlErrorResponse(reason=str(exc))
|
||||
|
||||
if not isinstance(res, LnurlWithdrawResponse):
|
||||
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
|
||||
try:
|
||||
check_callback_url(res.callback)
|
||||
except ValueError as exc:
|
||||
return LnurlErrorResponse(reason=f"Invalid callback URL: {exc!s}")
|
||||
|
||||
try:
|
||||
res2 = await lnurl_withdraw(
|
||||
res, payment_request, user_agent=settings.user_agent, timeout=10
|
||||
)
|
||||
except (LnurlResponseException, Exception) as exc:
|
||||
logger.warning(exc)
|
||||
return LnurlErrorResponse(reason=str(exc))
|
||||
if not isinstance(res2, (LnurlSuccessResponse, LnurlErrorResponse)):
|
||||
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
|
||||
|
||||
return res2
|
||||
@@ -1,12 +1,7 @@
|
||||
import json
|
||||
import ssl
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from math import ceil
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
@@ -15,7 +10,7 @@ from fastapi import (
|
||||
Query,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from lnurl import decode as lnurl_decode
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud.payments import (
|
||||
@@ -25,10 +20,8 @@ from lnbits.core.crud.payments import (
|
||||
from lnbits.core.models import (
|
||||
CancelInvoice,
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
DecodePayment,
|
||||
KeyType,
|
||||
PayLnurlWData,
|
||||
Payment,
|
||||
PaymentCountField,
|
||||
PaymentCountStat,
|
||||
@@ -48,13 +41,9 @@ from lnbits.decorators import (
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
check_callback_url,
|
||||
filter_dict_keys,
|
||||
generate_filter_params_openapi,
|
||||
)
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
from lnbits.wallets.base import InvoiceResponse
|
||||
|
||||
from ..crud import (
|
||||
@@ -284,92 +273,6 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
|
||||
)
|
||||
|
||||
|
||||
def _validate_lnurl_response(
|
||||
params: dict, domain: str, amount_msat: int
|
||||
) -> bolt11.Invoice:
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
)
|
||||
return invoice
|
||||
|
||||
|
||||
async def _fetch_lnurl_params(data: CreateLnurl, amount_msat: int, domain: str) -> dict:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
r: httpx.Response = await client.get(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
r.raise_for_status()
|
||||
except (httpx.HTTPError, ssl.SSLError) as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
) from exc
|
||||
return json.loads(r.text)
|
||||
|
||||
|
||||
@payment_router.post("/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Payment:
|
||||
domain = urlparse(data.callback).netloc
|
||||
check_callback_url(data.callback)
|
||||
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
|
||||
params = await _fetch_lnurl_params(data, amount_msat, domain)
|
||||
_validate_lnurl_response(params, domain, amount_msat)
|
||||
|
||||
extra = {}
|
||||
if params.get("successAction"):
|
||||
extra["success_action"] = params["successAction"]
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
if data.internal_memo is not None:
|
||||
if len(data.internal_memo) > 512:
|
||||
raise ValueError("Internal memo must be 512 characters or less.")
|
||||
extra["internal_memo"] = data.internal_memo
|
||||
if data.description is None:
|
||||
raise ValueError("Description is required")
|
||||
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=params["pr"],
|
||||
description=data.description,
|
||||
extra=extra,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@payment_router.get("/{payment_hash}")
|
||||
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
||||
@@ -428,62 +331,6 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
|
||||
async def api_payment_pay_with_nfc(
|
||||
payment_request: str,
|
||||
lnurl_data: PayLnurlWData,
|
||||
) -> JSONResponse:
|
||||
lnurl = lnurl_data.lnurl_w.lower()
|
||||
|
||||
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
|
||||
url = lnurl.replace("lnurlw://", "https://")
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
check_callback_url(url)
|
||||
lnurl_req = await client.get(url, timeout=10)
|
||||
if lnurl_req.is_error:
|
||||
return JSONResponse(
|
||||
{"success": False, "detail": "Error loading LNURL request"}
|
||||
)
|
||||
|
||||
lnurl_res = lnurl_req.json()
|
||||
|
||||
if lnurl_res.get("status") == "ERROR":
|
||||
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
|
||||
|
||||
if lnurl_res.get("tag") != "withdrawRequest":
|
||||
return JSONResponse(
|
||||
{"success": False, "detail": "Invalid LNURL-withdraw"}
|
||||
)
|
||||
|
||||
callback_url = lnurl_res["callback"]
|
||||
k1 = lnurl_res["k1"]
|
||||
|
||||
callback_req = await client.get(
|
||||
callback_url,
|
||||
params={"k1": k1, "pr": payment_request},
|
||||
timeout=10,
|
||||
)
|
||||
if callback_req.is_error:
|
||||
return JSONResponse(
|
||||
{"success": False, "detail": "Error loading callback request"}
|
||||
)
|
||||
|
||||
callback_res = callback_req.json()
|
||||
|
||||
if callback_res.get("status") == "ERROR":
|
||||
return JSONResponse(
|
||||
{"success": False, "detail": callback_res["reason"]}
|
||||
)
|
||||
else:
|
||||
return JSONResponse({"success": True, "detail": callback_res})
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
|
||||
|
||||
|
||||
@payment_router.post("/settle")
|
||||
async def api_payments_settle(
|
||||
data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ window.LNbits = {
|
||||
amount,
|
||||
memo,
|
||||
unit = 'sat',
|
||||
lnurlCallback = null,
|
||||
lnurlWithdraw = null,
|
||||
fiatProvider = null,
|
||||
internalMemo = null,
|
||||
payment_hash = null
|
||||
@@ -29,7 +29,7 @@ window.LNbits = {
|
||||
amount: amount,
|
||||
memo: memo,
|
||||
unit: unit,
|
||||
lnurl_callback: lnurlCallback,
|
||||
lnurl_withdraw: lnurlWithdraw,
|
||||
fiat_provider: fiatProvider,
|
||||
payment_hash: payment_hash
|
||||
}
|
||||
@@ -52,36 +52,6 @@ window.LNbits = {
|
||||
}
|
||||
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
|
||||
},
|
||||
payLnurl(
|
||||
wallet,
|
||||
callback,
|
||||
description_hash,
|
||||
amount,
|
||||
description = '',
|
||||
comment = '',
|
||||
unit = '',
|
||||
internalMemo = null
|
||||
) {
|
||||
const data = {
|
||||
callback,
|
||||
description_hash,
|
||||
amount,
|
||||
comment,
|
||||
description,
|
||||
unit
|
||||
}
|
||||
|
||||
if (internalMemo) {
|
||||
data.internal_memo = String(internalMemo)
|
||||
}
|
||||
|
||||
return this.request(
|
||||
'post',
|
||||
'/api/v1/payments/lnurl',
|
||||
wallet.adminkey,
|
||||
data
|
||||
)
|
||||
},
|
||||
cancelInvoice(wallet, paymentHash) {
|
||||
return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, {
|
||||
payment_hash: paymentHash
|
||||
@@ -92,11 +62,6 @@ window.LNbits = {
|
||||
preimage: preimage
|
||||
})
|
||||
},
|
||||
authLnurl(wallet, callback) {
|
||||
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
|
||||
callback
|
||||
})
|
||||
},
|
||||
createAccount(name) {
|
||||
return this.request('post', '/api/v1/account', null, {
|
||||
name: name
|
||||
|
||||
+61
-93
@@ -258,7 +258,7 @@ window.WalletPageLogic = {
|
||||
this.receive.data.amount,
|
||||
this.receive.data.memo,
|
||||
this.receive.unit,
|
||||
this.receive.lnurl && this.receive.lnurl.callback,
|
||||
this.receive.lnurlWithdraw,
|
||||
this.receive.fiatProvider,
|
||||
this.receive.data.internalMemo,
|
||||
this.receive.data.payment_hash
|
||||
@@ -276,24 +276,24 @@ window.WalletPageLogic = {
|
||||
}
|
||||
// TODO: lnurl_callback and lnurl_response
|
||||
// WITHDRAW
|
||||
if (response.data.lnurl_response !== null) {
|
||||
if (response.data.lnurl_response === false) {
|
||||
response.data.lnurl_response = `Unable to connect`
|
||||
if (response.data.extra.lnurl_response !== null) {
|
||||
if (response.data.extra.lnurl_response === false) {
|
||||
response.data.extra.lnurl_response = `Unable to connect`
|
||||
}
|
||||
|
||||
if (typeof response.data.lnurl_response === 'string') {
|
||||
if (typeof response.data.extra.lnurl_response === 'string') {
|
||||
// failure
|
||||
Quasar.Notify.create({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
|
||||
caption: response.data.lnurl_response
|
||||
caption: response.data.extra.lnurl_response
|
||||
})
|
||||
return
|
||||
} else if (response.data.lnurl_response === true) {
|
||||
} else if (response.data.extra.lnurl_response === true) {
|
||||
// success
|
||||
Quasar.Notify.create({
|
||||
timeout: 5000,
|
||||
timeout: 3000,
|
||||
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
|
||||
spinner: true
|
||||
})
|
||||
@@ -361,14 +361,15 @@ window.WalletPageLogic = {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.kind === 'pay') {
|
||||
if (data.tag === 'payRequest') {
|
||||
this.parse.lnurlpay = Object.freeze(data)
|
||||
this.parse.data.amount = data.minSendable / 1000
|
||||
} else if (data.kind === 'auth') {
|
||||
} else if (data.tag === 'login') {
|
||||
this.parse.lnurlauth = Object.freeze(data)
|
||||
} else if (data.kind === 'withdraw') {
|
||||
} else if (data.tag === 'withdrawRequest') {
|
||||
this.parse.show = false
|
||||
this.receive.show = true
|
||||
this.receive.lnurlWithdraw = Object.freeze(data)
|
||||
this.receive.status = 'pending'
|
||||
this.receive.paymentReq = null
|
||||
this.receive.paymentHash = null
|
||||
@@ -378,8 +379,9 @@ window.WalletPageLogic = {
|
||||
data.minWithdrawable / 1000,
|
||||
data.maxWithdrawable / 1000
|
||||
]
|
||||
const domain = data.callback.split('/')[2]
|
||||
this.receive.lnurl = {
|
||||
domain: data.domain,
|
||||
domain: domain,
|
||||
callback: data.callback,
|
||||
fixed: data.fixed
|
||||
}
|
||||
@@ -515,86 +517,49 @@ window.WalletPageLogic = {
|
||||
})
|
||||
},
|
||||
payLnurl() {
|
||||
const dismissPaymentMsg = Quasar.Notify.create({
|
||||
timeout: 0,
|
||||
message: 'Processing payment...'
|
||||
})
|
||||
LNbits.api
|
||||
.payLnurl(
|
||||
this.g.wallet,
|
||||
this.parse.lnurlpay.callback,
|
||||
this.parse.lnurlpay.description_hash,
|
||||
this.parse.data.amount * 1000,
|
||||
this.parse.lnurlpay.description.slice(0, 120),
|
||||
this.parse.data.comment,
|
||||
this.parse.data.unit,
|
||||
this.parse.data.internalMemo
|
||||
)
|
||||
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
|
||||
res: this.parse.lnurlpay,
|
||||
unit: this.parse.data.unit,
|
||||
amount: this.parse.data.amount * 1000,
|
||||
comment: this.parse.data.comment,
|
||||
internalMemo: this.parse.data.internalMemo
|
||||
})
|
||||
.then(response => {
|
||||
this.parse.show = false
|
||||
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
setTimeout(() => {
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
}, 40000)
|
||||
this.parse.paymentChecker = setInterval(() => {
|
||||
LNbits.api
|
||||
.getPayment(this.g.wallet, response.data.payment_hash)
|
||||
.then(res => {
|
||||
if (res.data.paid) {
|
||||
dismissPaymentMsg()
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
// show lnurlpay success action
|
||||
const extra = response.data.extra
|
||||
if (extra.success_action) {
|
||||
switch (extra.success_action.tag) {
|
||||
case 'url':
|
||||
Quasar.Notify.create({
|
||||
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`,
|
||||
caption: extra.success_action.description,
|
||||
html: true,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
break
|
||||
case 'message':
|
||||
Quasar.Notify.create({
|
||||
message: extra.success_action.message,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
break
|
||||
case 'aes':
|
||||
LNbits.api
|
||||
.getPayment(this.g.wallet, response.data.payment_hash)
|
||||
.then(({data: payment}) =>
|
||||
decryptLnurlPayAES(
|
||||
extra.success_action,
|
||||
payment.preimage
|
||||
)
|
||||
)
|
||||
.then(value => {
|
||||
Quasar.Notify.create({
|
||||
message: value,
|
||||
caption: extra.success_action.description,
|
||||
html: true,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
.catch(err => {
|
||||
dismissPaymentMsg()
|
||||
LNbits.utils.notifyApiError(err)
|
||||
if (response.data.extra.success_action) {
|
||||
const action = JSON.parse(response.data.extra.success_action)
|
||||
switch (action.tag) {
|
||||
case 'url':
|
||||
Quasar.Notify.create({
|
||||
message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
|
||||
caption: action.description,
|
||||
html: true,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
break
|
||||
case 'message':
|
||||
Quasar.Notify.create({
|
||||
message: action.message,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
break
|
||||
case 'aes':
|
||||
decryptLnurlPayAES(action, response.data.preimage)
|
||||
Quasar.Notify.create({
|
||||
message: value,
|
||||
caption: extra.success_action.description,
|
||||
html: true,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
authLnurl() {
|
||||
@@ -602,9 +567,13 @@ window.WalletPageLogic = {
|
||||
timeout: 10,
|
||||
message: 'Performing authentication...'
|
||||
})
|
||||
|
||||
LNbits.api
|
||||
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
|
||||
.request(
|
||||
'post',
|
||||
'/api/v1/lnurlauth',
|
||||
wallet.adminkey,
|
||||
this.parse.lnurlauth
|
||||
)
|
||||
.then(_ => {
|
||||
dismissAuthMsg()
|
||||
Quasar.Notify.create({
|
||||
@@ -615,10 +584,9 @@ window.WalletPageLogic = {
|
||||
this.parse.show = false
|
||||
})
|
||||
.catch(err => {
|
||||
dismissAuthMsg()
|
||||
if (err.response.data.reason) {
|
||||
Quasar.Notify.create({
|
||||
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
|
||||
message: `Authentication failed. ${this.parse.lnurlauth.callback} says:`,
|
||||
caption: err.response.data.reason,
|
||||
type: 'warning',
|
||||
timeout: 5000
|
||||
|
||||
Reference in New Issue
Block a user