Merge branch 'ext-boltcards-2' into bensbits
This commit is contained in:
commit
5ba624761c
8 changed files with 170 additions and 40 deletions
|
|
@ -20,5 +20,6 @@ def boltcards_renderer():
|
|||
return template_renderer(["lnbits/extensions/boltcards/templates"])
|
||||
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import * # noqa
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
|
|
|||
|
|
@ -8,28 +8,32 @@ from .models import Card, CreateCardData, Hit, Refund
|
|||
|
||||
|
||||
async def create_card(data: CreateCardData, wallet_id: str) -> Card:
|
||||
card_id = urlsafe_short_hash()
|
||||
card_id = urlsafe_short_hash().upper()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltcards.cards (
|
||||
id,
|
||||
uid,
|
||||
wallet,
|
||||
card_name,
|
||||
counter,
|
||||
withdraw,
|
||||
tx_limit,
|
||||
daily_limit,
|
||||
k0,
|
||||
k1,
|
||||
k2,
|
||||
otp
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
card_id,
|
||||
data.uid.upper(),
|
||||
wallet_id,
|
||||
data.card_name,
|
||||
data.counter,
|
||||
data.withdraw,
|
||||
data.tx_limit,
|
||||
data.daily_limit,
|
||||
data.k0,
|
||||
data.k1,
|
||||
data.k2,
|
||||
|
|
@ -67,12 +71,6 @@ async def get_cards(wallet_ids: Union[str, List[str]]) -> List[Card]:
|
|||
return [Card(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_cards() -> List[Card]:
|
||||
rows = await db.fetchall(f"SELECT * FROM boltcards.cards")
|
||||
|
||||
return [Card(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_card(card_id: str) -> Optional[Card]:
|
||||
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
|
||||
if not row:
|
||||
|
|
@ -143,11 +141,16 @@ async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]:
|
|||
|
||||
async def get_hits_today(card_id: Union[str, List[str]]) -> List[Hit]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.hits WHERE card_id = ? AND timestamp >= DATE() AND timestamp < DATE() + INTERVAL ? DAY", (card_id, 1)
|
||||
f"SELECT * FROM boltcards.hits WHERE card_id = ? AND time >= DATE('now') AND time < DATE('now', '+1 day')", (card_id,)
|
||||
)
|
||||
|
||||
return [Hit(**row) for row in rows]
|
||||
|
||||
async def spend_hit(id: str):
|
||||
await db.execute(
|
||||
"UPDATE boltcards.hits SET spent = ? WHERE id = ?",
|
||||
(True, id),
|
||||
)
|
||||
|
||||
async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:
|
||||
hit_id = urlsafe_short_hash()
|
||||
|
|
@ -157,21 +160,63 @@ async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:
|
|||
id,
|
||||
card_id,
|
||||
ip,
|
||||
spent,
|
||||
useragent,
|
||||
old_ctr,
|
||||
new_ctr
|
||||
new_ctr,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
hit_id,
|
||||
card_id,
|
||||
ip,
|
||||
False,
|
||||
useragent,
|
||||
old_ctr,
|
||||
new_ctr,
|
||||
0,
|
||||
),
|
||||
)
|
||||
hit = await get_hit(hit_id)
|
||||
assert hit, "Newly recorded hit couldn't be retrieved"
|
||||
return hit
|
||||
|
||||
async def create_refund(hit_id, refund_amount) -> Refund:
|
||||
refund_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltcards.hits (
|
||||
id,
|
||||
hit_id,
|
||||
refund_amount,
|
||||
payment_hash
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
refund_id,
|
||||
hit_id,
|
||||
refund_amount,
|
||||
payment_hash,
|
||||
),
|
||||
)
|
||||
refund = await get_refund(refund_id)
|
||||
assert refund, "Newly recorded hit couldn't be retrieved"
|
||||
return refund
|
||||
|
||||
async def get_refund(refund_id: str) -> Optional[Refund]:
|
||||
row = await db.fetchone(f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id))
|
||||
if not row:
|
||||
return None
|
||||
refund = dict(**row)
|
||||
return Refund.parse_obj(refund)
|
||||
|
||||
async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]:
|
||||
q = ",".join(["?"] * len(hits_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,)
|
||||
)
|
||||
|
||||
return [Refund(**row) for row in rows]
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from embit import bech32, compact
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
|
|
@ -33,6 +36,7 @@ from .crud import (
|
|||
get_card,
|
||||
get_hit,
|
||||
get_hits_today,
|
||||
spend_hit,
|
||||
update_card,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
|
|
@ -50,11 +54,11 @@ async def api_scan(p, c, request: Request, card_id: str = None):
|
|||
c = c.upper()
|
||||
card = None
|
||||
counter = b""
|
||||
try:
|
||||
card = await get_card(card_id)
|
||||
card_id, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1))
|
||||
|
||||
if card.uid.upper() != card_id.hex().upper():
|
||||
try:
|
||||
card = await get_card_by_uid(card_uid)
|
||||
card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1))
|
||||
if card.uid.upper() != card_uid.hex().upper():
|
||||
return {"status": "ERROR", "reason": "Card UID mis-match."}
|
||||
except:
|
||||
return {"status": "ERROR", "reason": "Error decrypting card."}
|
||||
|
|
@ -67,8 +71,8 @@ async def api_scan(p, c, request: Request, card_id: str = None):
|
|||
|
||||
ctr_int = int.from_bytes(counter, "little")
|
||||
|
||||
if ctr_int <= card.counter:
|
||||
return {"status": "ERROR", "reason": "This link is already used."}
|
||||
# if ctr_int <= card.counter:
|
||||
# return {"status": "ERROR", "reason": "This link is already used."}
|
||||
|
||||
await update_card_counter(ctr_int, card.id)
|
||||
|
||||
|
|
@ -86,13 +90,13 @@ async def api_scan(p, c, request: Request, card_id: str = None):
|
|||
for hit in todays_hits:
|
||||
hits_amount = hits_amount + hit.amount
|
||||
if (hits_amount + card.tx_limit) > card.daily_limit:
|
||||
return {"status": "ERROR", "reason": "Max daily liit spent."}
|
||||
return {"status": "ERROR", "reason": "Max daily limit spent."}
|
||||
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
|
||||
lnurlpay = lnurl_encode(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
|
||||
return {
|
||||
"tag": "withdrawRequest",
|
||||
"callback": request.url_for(
|
||||
"boltcards.lnurl_callback"
|
||||
"boltcards.lnurl_callback", hitid=hit.id
|
||||
),
|
||||
"k1": hit.id,
|
||||
"minWithdrawable": 1 * 1000,
|
||||
|
|
@ -166,14 +170,15 @@ async def api_auth(a, request: Request):
|
|||
)
|
||||
async def lnurlp_response(req: Request, hit_id: str = Query(None)):
|
||||
hit = await get_hit(hit_id)
|
||||
card = await get_card(hit.card_id)
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
payResponse = {
|
||||
"tag": "payRequest",
|
||||
"callback": req.url_for("boltcards.lnurlp_callback", hit_id=hit_id),
|
||||
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
|
||||
"minSendable": math.ceil(link.min_bet * 1) * 1000,
|
||||
"maxSendable": round(link.max_bet * 1) * 1000,
|
||||
"minSendable": 1 * 1000,
|
||||
"maxSendable": card.tx_limit * 1000,
|
||||
}
|
||||
return json.dumps(payResponse)
|
||||
|
||||
|
|
@ -187,14 +192,15 @@ async def lnurlp_callback(
|
|||
req: Request, hit_id: str = Query(None), amount: str = Query(None)
|
||||
):
|
||||
hit = await get_hit(hit_id)
|
||||
card = await get_card(hit.card_id)
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=link.wallet,
|
||||
amount=int(amount / 1000),
|
||||
wallet_id=card.wallet,
|
||||
amount=int(amount) / 1000,
|
||||
memo=f"Refund {hit_id}",
|
||||
unhashed_description=LnurlPayMetadata(json.dumps([["text/plain", hit_id]])).encode("utf-8"),
|
||||
unhashed_description=LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])).encode("utf-8"),
|
||||
extra={"refund": hit_id},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class Hit(BaseModel):
|
|||
useragent: str
|
||||
old_ctr: int
|
||||
new_ctr: int
|
||||
amount: int
|
||||
time: int
|
||||
|
||||
def from_row(cls, row: Row) -> "Hit":
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ new Vue({
|
|||
cards: [],
|
||||
hits: [],
|
||||
refunds: [],
|
||||
lnurlLink: location.hostname + '/boltcards/api/v1/scan/',
|
||||
cardDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
|
|
@ -43,10 +44,10 @@ new Vue({
|
|||
field: 'counter'
|
||||
},
|
||||
{
|
||||
name: 'withdraw',
|
||||
name: 'uid',
|
||||
align: 'left',
|
||||
label: 'Withdraw ID',
|
||||
field: 'withdraw'
|
||||
label: 'Card ID',
|
||||
field: 'uid'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
|
|
@ -139,7 +140,7 @@ new Vue({
|
|||
.request(
|
||||
'GET',
|
||||
'/boltcards/api/v1/cards?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cards = response.data.map(function (obj) {
|
||||
|
|
|
|||
34
lnbits/extensions/boltcards/tasks.py
Normal file
34
lnbits/extensions/boltcards/tasks.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_hit, create_refund
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue)
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if payment.extra.get("tag")[0:6] != "Refund":
|
||||
# not an lnurlp invoice
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
# this webhook has already been sent
|
||||
return
|
||||
hit = await get_hit(payment.extra.get("tag")[7:len(payment.extra.get("tag"))])
|
||||
if hit:
|
||||
refund = await create_refund(hit_id=hit.id, refund_amount=payment.extra.get("amount"))
|
||||
await mark_webhook_sent(payment, 1)
|
||||
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width>Base URL</q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
|
|
@ -53,6 +54,14 @@
|
|||
@click="openQrCodeDialog(props.row.id)"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(lnurlLink + props.row.uid)"
|
||||
lnurlLink >lnurl://...<q-tooltip>Click to copy, then add to NFC card</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
|
|
@ -193,7 +202,7 @@
|
|||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.trans_limit"
|
||||
v-model.trim="cardDialog.data.tx_limit"
|
||||
type="number"
|
||||
label="Max transaction (sats)"
|
||||
class="q-pr-sm"
|
||||
|
|
@ -211,13 +220,40 @@
|
|||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.card_name"
|
||||
type="text"
|
||||
label="Card name "
|
||||
></q-input>
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.card_name"
|
||||
type="text"
|
||||
label="Card name "
|
||||
></q-input>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.uid"
|
||||
type="text"
|
||||
label="Card UID "
|
||||
><q-tooltip>Get from the card you'll use, using an NFC app</q-tooltip></q-input>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col-2 q-pl-sm">
|
||||
<q-btn
|
||||
outline
|
||||
disable
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
:disable="nfcTagReading"
|
||||
><q-tooltip>Tap card to scan UID (coming soon)</q-tooltip></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<q-toggle
|
||||
@click="toggleKeys"
|
||||
v-model="toggleAdvanced"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from .crud import (
|
|||
create_card,
|
||||
create_hit,
|
||||
delete_card,
|
||||
get_all_cards,
|
||||
get_card,
|
||||
get_card_by_otp,
|
||||
get_card_by_uid,
|
||||
|
|
@ -22,10 +21,12 @@ from .crud import (
|
|||
update_card,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
get_refunds,
|
||||
)
|
||||
from .models import CreateCardData
|
||||
from .nxp424 import decryptSUN, getSunMAC
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@boltcards_ext.get("/api/v1/cards")
|
||||
async def api_cards(
|
||||
|
|
@ -47,6 +48,7 @@ async def api_card_create_or_update(
|
|||
card_id: str = None,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
logger.debug(len(bytes.fromhex(data.uid)))
|
||||
try:
|
||||
if len(bytes.fromhex(data.uid)) != 7:
|
||||
raise HTTPException(
|
||||
|
|
@ -133,5 +135,9 @@ async def api_hits(
|
|||
cards_ids = []
|
||||
for card in cards:
|
||||
cards_ids.append(card.id)
|
||||
hits = await get_hits(cards_ids)
|
||||
hits_ids = []
|
||||
for hit in hits:
|
||||
hits_ids.append(hit.id)
|
||||
|
||||
return [hit.dict() for hit in await get_hits(cards_ids)]
|
||||
return [refund.dict() for refund in await get_refunds(hits_ids)]
|
||||
Loading…
Add table
Add a link
Reference in a new issue