feat: use lnurl lib (#60)
* feat: use lnurl lib use new lnurl lib from v1.3.0 * mobile display page fix --------- Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
This commit is contained in:
parent
7bdf7db2c4
commit
f08060d0f4
5 changed files with 125 additions and 93 deletions
|
|
@ -2,7 +2,8 @@
|
||||||
"name": "Bolt Cards",
|
"name": "Bolt Cards",
|
||||||
"short_description": "Self custody Bolt Cards with one time LNURLw",
|
"short_description": "Self custody Bolt Cards with one time LNURLw",
|
||||||
"tile": "/boltcards/static/image/boltcard.png",
|
"tile": "/boltcards/static/image/boltcard.png",
|
||||||
"min_lnbits_version": "1.0.0",
|
"version": "1.1.0",
|
||||||
|
"min_lnbits_version": "1.3.0",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
"name": "dni",
|
"name": "dni",
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,16 @@ card.card_name }{% endblock %} {% block page %}
|
||||||
:color="hit.spent > 0 ? 'green' : 'grey'"
|
:color="hit.spent > 0 ? 'green' : 'grey'"
|
||||||
/>
|
/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section class="overflow-hidden">
|
||||||
<q-item-label
|
<q-item-label
|
||||||
>ID: ${hit.id} ${refunds.some(r => r.hit_id == hit.id) ?
|
>ID: ${hit.id} ${refunds.some(r => r.hit_id == hit.id) ?
|
||||||
'(Refunded)' : null}</q-item-label
|
'(Refunded)' : null}
|
||||||
>
|
<q-tooltip><span v-text="hit.id"></span></q-tooltip>
|
||||||
<q-item-label caption lines="1">IP: ${hit.ip}</q-item-label>
|
</q-item-label>
|
||||||
|
<q-item-label caption lines="1"
|
||||||
|
>IP: ${hit.ip}<q-tooltip
|
||||||
|
><span v-text="hit.ip"></span></q-tooltip
|
||||||
|
></q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
||||||
<q-item-section side top>
|
<q-item-section side top>
|
||||||
|
|
|
||||||
|
|
@ -381,7 +381,6 @@
|
||||||
<div class="col q-mt-lg text-center">
|
<div class="col q-mt-lg text-center">
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
:value="qrCodeDialog.data.link"
|
:value="qrCodeDialog.data.link"
|
||||||
class="rounded-borders"
|
|
||||||
v-show="!qrCodeDialog.wipe"
|
v-show="!qrCodeDialog.wipe"
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
<p class="text-center" v-show="!qrCodeDialog.wipe">
|
<p class="text-center" v-show="!qrCodeDialog.wipe">
|
||||||
|
|
@ -396,7 +395,6 @@
|
||||||
</p>
|
</p>
|
||||||
<lnbits-qrcode
|
<lnbits-qrcode
|
||||||
:value="qrCodeDialog.data_wipe"
|
:value="qrCodeDialog.data_wipe"
|
||||||
class="rounded-borders"
|
|
||||||
v-show="qrCodeDialog.wipe"
|
v-show="qrCodeDialog.wipe"
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
<p class="text-center" v-show="qrCodeDialog.wipe">
|
<p class="text-center" v-show="qrCodeDialog.wipe">
|
||||||
|
|
|
||||||
21
views_api.py
21
views_api.py
|
|
@ -106,7 +106,11 @@ async def api_card_create(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
)
|
)
|
||||||
card = await create_card(wallet_id=wallet.wallet.id, data=data)
|
card = await create_card(wallet_id=wallet.wallet.id, data=data)
|
||||||
assert card, "create_card should always return a card"
|
if not card:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Could not create card.",
|
||||||
|
)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -117,19 +121,25 @@ async def enable_card(
|
||||||
card_id: str,
|
card_id: str,
|
||||||
enable: bool,
|
enable: bool,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
):
|
) -> Card:
|
||||||
card = await get_card(card_id)
|
card = await get_card(card_id)
|
||||||
if not card:
|
if not card:
|
||||||
raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND)
|
raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND)
|
||||||
if card.wallet != wallet.wallet.id:
|
if card.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||||
card = await enable_disable_card(enable=enable, card_id=card_id)
|
card = await enable_disable_card(enable=enable, card_id=card_id)
|
||||||
assert card
|
if not card:
|
||||||
return card.dict()
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Could not update card.",
|
||||||
|
)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
@boltcards_api_router.delete("/api/v1/cards/{card_id}")
|
@boltcards_api_router.delete("/api/v1/cards/{card_id}")
|
||||||
async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
async def api_card_delete(
|
||||||
|
card_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
) -> None:
|
||||||
card = await get_card(card_id)
|
card = await get_card(card_id)
|
||||||
|
|
||||||
if not card:
|
if not card:
|
||||||
|
|
@ -141,7 +151,6 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi
|
||||||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||||
|
|
||||||
await delete_card(card_id)
|
await delete_card(card_id)
|
||||||
return "", HTTPStatus.NO_CONTENT
|
|
||||||
|
|
||||||
|
|
||||||
@boltcards_api_router.get("/api/v1/hits")
|
@boltcards_api_router.get("/api/v1/hits")
|
||||||
|
|
|
||||||
180
views_lnurl.py
180
views_lnurl.py
|
|
@ -6,10 +6,20 @@ from urllib.parse import urlparse
|
||||||
import bolt11
|
import bolt11
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from lnbits.core.services import create_invoice, pay_invoice
|
from lnbits.core.services import create_invoice, pay_invoice
|
||||||
from lnurl import encode as lnurl_encode
|
from lnurl import (
|
||||||
from lnurl.types import LnurlPayMetadata
|
CallbackUrl,
|
||||||
from loguru import logger
|
LightningInvoice,
|
||||||
from starlette.responses import HTMLResponse
|
LnurlErrorResponse,
|
||||||
|
LnurlPayActionResponse,
|
||||||
|
LnurlPayMetadata,
|
||||||
|
LnurlPayResponse,
|
||||||
|
LnurlSuccessResponse,
|
||||||
|
LnurlWithdrawResponse,
|
||||||
|
Max144Str,
|
||||||
|
MessageAction,
|
||||||
|
MilliSatoshi,
|
||||||
|
)
|
||||||
|
from pydantic import parse_obj_as
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_hit,
|
create_hit,
|
||||||
|
|
@ -31,7 +41,9 @@ boltcards_lnurl_router = APIRouter()
|
||||||
|
|
||||||
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
|
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
|
||||||
@boltcards_lnurl_router.get("/api/v1/scan/{external_id}")
|
@boltcards_lnurl_router.get("/api/v1/scan/{external_id}")
|
||||||
async def api_scan(p, c, request: Request, external_id: str):
|
async def api_scan(
|
||||||
|
p, c, request: Request, external_id: str
|
||||||
|
) -> LnurlWithdrawResponse | LnurlErrorResponse:
|
||||||
# some wallets send everything as lower case, no bueno
|
# some wallets send everything as lower case, no bueno
|
||||||
p = p.upper()
|
p = p.upper()
|
||||||
c = c.upper()
|
c = c.upper()
|
||||||
|
|
@ -39,27 +51,28 @@ async def api_scan(p, c, request: Request, external_id: str):
|
||||||
counter = b""
|
counter = b""
|
||||||
card = await get_card_by_external_id(external_id)
|
card = await get_card_by_external_id(external_id)
|
||||||
if not card:
|
if not card:
|
||||||
return {"status": "ERROR", "reason": "No card."}
|
return LnurlErrorResponse(reason="Card not found.")
|
||||||
if not card.enable:
|
if not card.enable:
|
||||||
return {"status": "ERROR", "reason": "Card is disabled."}
|
return LnurlErrorResponse(reason="Card is disabled.")
|
||||||
try:
|
try:
|
||||||
card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1))
|
card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1))
|
||||||
if card.uid.upper() != card_uid.hex().upper():
|
if card.uid.upper() != card_uid.hex().upper():
|
||||||
return {"status": "ERROR", "reason": "Card UID mis-match."}
|
return LnurlErrorResponse(reason="Card UID mis-match.")
|
||||||
if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
|
if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
|
||||||
return {"status": "ERROR", "reason": "CMAC does not check."}
|
return LnurlErrorResponse(reason="CMAC does not check.")
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"status": "ERROR", "reason": "Error decrypting card."}
|
return LnurlErrorResponse(reason="Error decrypting card.")
|
||||||
|
|
||||||
ctr_int = int.from_bytes(counter, "little")
|
ctr_int = int.from_bytes(counter, "little")
|
||||||
|
|
||||||
if ctr_int <= card.counter:
|
if ctr_int <= card.counter:
|
||||||
return {"status": "ERROR", "reason": "This link is already used."}
|
return LnurlErrorResponse(reason="This link is already used.")
|
||||||
|
|
||||||
await update_card_counter(ctr_int, card.id)
|
await update_card_counter(ctr_int, card.id)
|
||||||
|
|
||||||
# gathering some info for hit record
|
# gathering some info for hit record
|
||||||
assert request.client
|
if not request.client:
|
||||||
|
return LnurlErrorResponse(reason="Cannot get client info.")
|
||||||
ip = request.client.host
|
ip = request.client.host
|
||||||
if "x-real-ip" in request.headers:
|
if "x-real-ip" in request.headers:
|
||||||
ip = request.headers["x-real-ip"]
|
ip = request.headers["x-real-ip"]
|
||||||
|
|
@ -73,27 +86,25 @@ async def api_scan(p, c, request: Request, external_id: str):
|
||||||
for hit in todays_hits:
|
for hit in todays_hits:
|
||||||
hits_amount += hit.amount
|
hits_amount += hit.amount
|
||||||
if hits_amount > int(card.daily_limit):
|
if hits_amount > int(card.daily_limit):
|
||||||
return {"status": "ERROR", "reason": "Max daily limit spent."}
|
return LnurlErrorResponse(reason="Max daily limit spent.")
|
||||||
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
|
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
|
||||||
|
|
||||||
# the raw lnurl
|
|
||||||
lnurlpay_raw = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
|
|
||||||
# bech32 encoded lnurl
|
|
||||||
lnurlpay_bech32 = lnurl_encode(lnurlpay_raw)
|
|
||||||
# create a lud17 lnurlp to support lud19, add payLink field of the withdrawRequest
|
# create a lud17 lnurlp to support lud19, add payLink field of the withdrawRequest
|
||||||
lnurlpay_nonbech32_lud17 = lnurlpay_raw.replace("https://", "lnurlp://").replace(
|
lnurlpay_url = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
|
||||||
"http://", "lnurlp://"
|
pay_link = lnurlpay_url.replace("http://", "lnurlp://").replace(
|
||||||
|
"https://", "lnurlp://"
|
||||||
|
)
|
||||||
|
callback_url = parse_obj_as(
|
||||||
|
CallbackUrl, str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id))
|
||||||
|
)
|
||||||
|
return LnurlWithdrawResponse(
|
||||||
|
callback=callback_url,
|
||||||
|
k1=hit.id,
|
||||||
|
minWithdrawable=MilliSatoshi(1000),
|
||||||
|
maxWithdrawable=MilliSatoshi(int(card.tx_limit) * 1000),
|
||||||
|
defaultDescription=f"Boltcard (refund address {pay_link})",
|
||||||
|
payLink=pay_link, # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
|
||||||
"tag": "withdrawRequest",
|
|
||||||
"callback": str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)),
|
|
||||||
"k1": hit.id,
|
|
||||||
"minWithdrawable": 1 * 1000,
|
|
||||||
"maxWithdrawable": int(card.tx_limit) * 1000,
|
|
||||||
"defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay_bech32})",
|
|
||||||
"payLink": lnurlpay_nonbech32_lud17, # LUD-19 compatibility
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@boltcards_lnurl_router.get(
|
@boltcards_lnurl_router.get(
|
||||||
|
|
@ -105,34 +116,32 @@ async def lnurl_callback(
|
||||||
hit_id: str,
|
hit_id: str,
|
||||||
k1: str = Query(None),
|
k1: str = Query(None),
|
||||||
pr: str = Query(None),
|
pr: str = Query(None),
|
||||||
):
|
) -> LnurlErrorResponse | LnurlSuccessResponse:
|
||||||
# TODO: why no hit_id? its not used why is it passed by url?
|
|
||||||
logger.debug(f"TODO: why no hit_id? {hit_id}")
|
|
||||||
if not k1:
|
if not k1:
|
||||||
return {"status": "ERROR", "reason": "Missing K1 token"}
|
return LnurlErrorResponse(reason="Missing K1 token")
|
||||||
|
if k1 != hit_id:
|
||||||
hit = await get_hit(k1)
|
return LnurlErrorResponse(reason="K1 token does not match.")
|
||||||
|
|
||||||
|
hit = await get_hit(hit_id)
|
||||||
if not hit:
|
if not hit:
|
||||||
return {
|
return LnurlErrorResponse(reason="LNURL-withdraw record not found.")
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Record not found for this charge (bad k1)",
|
|
||||||
}
|
|
||||||
if hit.spent:
|
if hit.spent:
|
||||||
return {"status": "ERROR", "reason": "Payment already claimed"}
|
return LnurlErrorResponse(reason="Payment already claimed.")
|
||||||
if not pr:
|
if not pr:
|
||||||
return {"status": "ERROR", "reason": "Missing payment request"}
|
return LnurlErrorResponse(reason="Missing payment request.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
invoice = bolt11.decode(pr)
|
invoice = bolt11.decode(pr)
|
||||||
except bolt11.Bolt11Exception:
|
except bolt11.Bolt11Exception:
|
||||||
return {"status": "ERROR", "reason": "Failed to decode payment request"}
|
return LnurlErrorResponse(reason="Failed to decode payment request.")
|
||||||
|
if not invoice.amount_msat:
|
||||||
|
return LnurlErrorResponse(reason="Invoice has no amount.")
|
||||||
card = await get_card(hit.card_id)
|
card = await get_card(hit.card_id)
|
||||||
assert card
|
if not card:
|
||||||
assert invoice.amount_msat, "Invoice amount is missing"
|
return LnurlErrorResponse(reason="Card not found.")
|
||||||
hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000))
|
hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000))
|
||||||
assert hit
|
if not hit:
|
||||||
|
return LnurlErrorResponse(reason="Failed to update hit as spent.")
|
||||||
try:
|
try:
|
||||||
await pay_invoice(
|
await pay_invoice(
|
||||||
wallet_id=card.wallet,
|
wallet_id=card.wallet,
|
||||||
|
|
@ -140,9 +149,9 @@ async def lnurl_callback(
|
||||||
max_sat=int(card.tx_limit),
|
max_sat=int(card.tx_limit),
|
||||||
extra={"tag": "boltcards", "hit": hit.id},
|
extra={"tag": "boltcards", "hit": hit.id},
|
||||||
)
|
)
|
||||||
return {"status": "OK"}
|
return LnurlSuccessResponse()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"status": "ERROR", "reason": f"Payment failed - {exc}"}
|
return LnurlErrorResponse(reason=f"Payment failed - {exc}")
|
||||||
|
|
||||||
|
|
||||||
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
||||||
|
|
@ -222,43 +231,27 @@ async def api_auth_post(a: str, request: Request, data: UIDPost, wipe: bool = Fa
|
||||||
|
|
||||||
|
|
||||||
###############LNURLPAY REFUNDS#################
|
###############LNURLPAY REFUNDS#################
|
||||||
|
|
||||||
|
|
||||||
@boltcards_lnurl_router.get(
|
|
||||||
"/api/v1/lnurlp/{hit_id}",
|
|
||||||
response_class=HTMLResponse,
|
|
||||||
name="boltcards.lnurlp_response",
|
|
||||||
)
|
|
||||||
async def lnurlp_response(req: Request, hit_id: str):
|
|
||||||
hit = await get_hit(hit_id)
|
|
||||||
assert hit
|
|
||||||
card = await get_card(hit.card_id)
|
|
||||||
assert card
|
|
||||||
if not hit:
|
|
||||||
return {"status": "ERROR", "reason": "LNURL-pay record not found."}
|
|
||||||
if not card.enable:
|
|
||||||
return {"status": "ERROR", "reason": "Card is disabled."}
|
|
||||||
pay_response = {
|
|
||||||
"tag": "payRequest",
|
|
||||||
"callback": str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)),
|
|
||||||
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
|
|
||||||
"minSendable": 1 * 1000,
|
|
||||||
"maxSendable": int(card.tx_limit) * 1000,
|
|
||||||
}
|
|
||||||
return json.dumps(pay_response)
|
|
||||||
|
|
||||||
|
|
||||||
@boltcards_lnurl_router.get(
|
@boltcards_lnurl_router.get(
|
||||||
"/api/v1/lnurlp/cb/{hit_id}",
|
"/api/v1/lnurlp/cb/{hit_id}",
|
||||||
name="boltcards.lnurlp_callback",
|
name="boltcards.lnurlp_callback",
|
||||||
)
|
)
|
||||||
async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
|
async def lnurlp_callback(
|
||||||
|
hit_id: str, amount: str = Query(None)
|
||||||
|
) -> LnurlPayActionResponse | LnurlErrorResponse:
|
||||||
hit = await get_hit(hit_id)
|
hit = await get_hit(hit_id)
|
||||||
assert hit
|
|
||||||
card = await get_card(hit.card_id)
|
|
||||||
assert card
|
|
||||||
if not hit:
|
if not hit:
|
||||||
return {"status": "ERROR", "reason": "LNURL-pay record not found."}
|
return LnurlErrorResponse(reason="LNURL-pay record not found.")
|
||||||
|
card = await get_card(hit.card_id)
|
||||||
|
if not card:
|
||||||
|
return LnurlErrorResponse(reason="Card not found.")
|
||||||
|
if not card.enable:
|
||||||
|
return LnurlErrorResponse(reason="Card is disabled.")
|
||||||
|
if not amount:
|
||||||
|
return LnurlErrorResponse(reason="Missing amount.")
|
||||||
|
if int(amount) < 1000:
|
||||||
|
return LnurlErrorResponse(reason="Amount too low.")
|
||||||
|
if int(amount) > int(card.tx_limit) * 1000:
|
||||||
|
return LnurlErrorResponse(reason="Amount too high.")
|
||||||
|
|
||||||
payment = await create_invoice(
|
payment = await create_invoice(
|
||||||
wallet_id=card.wallet,
|
wallet_id=card.wallet,
|
||||||
|
|
@ -269,5 +262,32 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
|
||||||
).encode(),
|
).encode(),
|
||||||
extra={"refund": hit_id},
|
extra={"refund": hit_id},
|
||||||
)
|
)
|
||||||
|
action = MessageAction(message=Max144Str("Refunded!"))
|
||||||
|
invoice = parse_obj_as(LightningInvoice, payment.bolt11)
|
||||||
|
return LnurlPayActionResponse(pr=invoice, successAction=action)
|
||||||
|
|
||||||
return {"pr": payment.bolt11, "routes": []}
|
|
||||||
|
@boltcards_lnurl_router.get(
|
||||||
|
"/api/v1/lnurlp/{hit_id}",
|
||||||
|
name="boltcards.lnurlp_response",
|
||||||
|
)
|
||||||
|
async def lnurlp_response(
|
||||||
|
req: Request, hit_id: str
|
||||||
|
) -> LnurlPayResponse | LnurlErrorResponse:
|
||||||
|
hit = await get_hit(hit_id)
|
||||||
|
if not hit:
|
||||||
|
return LnurlErrorResponse(reason="LNURL-pay hit not found.")
|
||||||
|
card = await get_card(hit.card_id)
|
||||||
|
if not card:
|
||||||
|
return LnurlErrorResponse(reason="Card not found.")
|
||||||
|
if not card.enable:
|
||||||
|
return LnurlErrorResponse(reason="Card is disabled.")
|
||||||
|
callback_url = parse_obj_as(
|
||||||
|
CallbackUrl, str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id))
|
||||||
|
)
|
||||||
|
return LnurlPayResponse(
|
||||||
|
callback=callback_url,
|
||||||
|
minSendable=MilliSatoshi(1000),
|
||||||
|
maxSendable=MilliSatoshi(int(card.tx_limit) * 1000),
|
||||||
|
metadata=LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue