diff --git a/lnbits/extensions/boltcards/__init__.py b/lnbits/extensions/boltcards/__init__.py index f5336341..5fcbd12e 100644 --- a/lnbits/extensions/boltcards/__init__.py +++ b/lnbits/extensions/boltcards/__init__.py @@ -20,6 +20,6 @@ boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"]) def boltcards_renderer(): return template_renderer(["lnbits/extensions/boltcards/templates"]) - +from .lnurl import * # noqa from .views import * # noqa from .views_api import * # noqa diff --git a/lnbits/extensions/boltcards/lnurl.py b/lnbits/extensions/boltcards/lnurl.py new file mode 100644 index 00000000..51a85e8a --- /dev/null +++ b/lnbits/extensions/boltcards/lnurl.py @@ -0,0 +1,197 @@ +import base64 +import hashlib +import hmac +from http import HTTPStatus +from io import BytesIO +from typing import Optional + +from embit import bech32, compact +from fastapi import Request +from fastapi.param_functions import Query +from starlette.exceptions import HTTPException + +from lnbits.core.services import create_invoice +from lnbits.core.views.api import pay_invoice + +from lnurl import Lnurl, LnurlWithdrawResponse +from lnurl import encode as lnurl_encode # type: ignore +from lnurl.types import LnurlPayMetadata # type: ignore + +from . import boltcards_ext +from .crud import ( + create_hit, + get_card, + get_card_by_otp, + get_card_by_uid, + get_hit, + update_card, + update_card_counter, + update_card_otp, +) +from .models import CreateCardData +from .nxp424 import decryptSUN, getSunMAC + +# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 +@boltcards_ext.get("/api/v1/scan/{card_uid}") +async def api_scan(p, c, request: Request, card_uid: str = None): + # some wallets send everything as lower case, no bueno + p = p.upper() + c = c.upper() + card = None + counter = b"" + 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."} + + if card == None: + return {"status": "ERROR", "reason": "Unknown card."} + + if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper(): + return {"status": "ERROR", "reason": "CMAC does not check."} + + ctr_int = int.from_bytes(counter, "little") + + if ctr_int <= card.counter: + return {"status": "ERROR", "reason": "This link is already used."} + + await update_card_counter(ctr_int, card.id) + + # gathering some info for hit record + ip = request.client.host + if "x-real-ip" in request.headers: + ip = request.headers["x-real-ip"] + elif "x-forwarded-for" in request.headers: + ip = request.headers["x-forwarded-for"] + + agent = request.headers["user-agent"] if "user-agent" in request.headers else "" + todays_hits = await get_hits_today(card.id) + int hits_amount = 0 + 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."} + hit = await create_hit(card.id, ip, agent, card.counter, ctr_int) + + # link = await get_withdraw_link(card.withdraw, 0) + return link.lnurl_response(request) + return { + "tag": "withdrawRequest", + "callback": request.url_for( + "boltcards.lnurl_callback" + ), + "k1": hit.id, + "minWithdrawable": 1 * 1000, + "maxWithdrawable": card.tx_limit * 1000, + "defaultDescription": f"Boltcard (Refunds address {lnurl_encode(req.url_for("boltcards.lnurlp_response", hit_id=hit.id))})", + } + +@boltcards_ext.get( + "/api/v1/lnurl/cb/{hitid}", + status_code=HTTPStatus.OK, + name="boltcards.lnurl_callback", +) +async def lnurl_callback( + request: Request, + pr: str = Query(None), + k1: str = Query(None), +): + hit = await get_hit(k1) + card = await get_card(hit.id) + if not hit: + return {"status": "ERROR", "reason": f"LNURL-pay record not found."} + + if pr: + if hit.id != k1: + return {"status": "ERROR", "reason": "Bad K1"} + if hit.spent: + return {"status": "ERROR", "reason": f"Payment already claimed"} + hit = await spend_hit(hit.id) + if not hit: + return {"status": "ERROR", "reason": f"Payment failed"} + await pay_invoice( + wallet_id=card.wallet, + payment_request=pr, + max_sat=card.tx_limit / 1000, + extra={"tag": "boltcard"}, + ) + return {"status": "OK"} + else: + return {"status": "ERROR", "reason": f"Payment failed"} + + +# /boltcards/api/v1/auth?a=00000000000000000000000000000000 +@boltcards_ext.get("/api/v1/auth") +async def api_auth(a, request: Request): + if a == "00000000000000000000000000000000": + response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32} + return response + + card = await get_card_by_otp(a) + + if not card: + raise HTTPException( + detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + new_otp = secrets.token_hex(16) + print(card.otp) + print(new_otp) + await update_card_otp(new_otp, card.id) + + response = {"k0": card.k0, "k1": card.k1, "k2": card.k2} + + return response + +###############LNURLPAY REFUNDS################# + +@satsdice_ext.get( + "/api/v1/lnurlp/{hit_id}", + response_class=HTMLResponse, + name="boltcards.lnurlp_response", +) +async def api_lnurlp_response(req: Request, hit_id: str = Query(None)): + hit = await get_hit(hit_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, + } + return json.dumps(payResponse) + + +@satsdice_ext.get( + "/api/v1/lnurlp/cb/{hit_id}", + response_class=HTMLResponse, + name="boltcards.lnurlp_callback", +) +async def api_lnurlp_callback( + req: Request, hit_id: str = Query(None), amount: str = Query(None) +): + hit = await get_hit(hit_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), + memo=f"Refund {hit_id}", + unhashed_description=LnurlPayMetadata(json.dumps([["text/plain", hit_id]])).encode("utf-8"), + extra={"refund": hit_id}, + ) + + payResponse = {"pr": payment_request, "successAction": success_action, "routes": []} + + return json.dumps(payResponse) + + + + diff --git a/lnbits/extensions/boltcards/migrations.py b/lnbits/extensions/boltcards/migrations.py index 7dc5acb4..99f42583 100644 --- a/lnbits/extensions/boltcards/migrations.py +++ b/lnbits/extensions/boltcards/migrations.py @@ -10,7 +10,8 @@ async def m001_initial(db): card_name TEXT NOT NULL, uid TEXT NOT NULL, counter INT NOT NULL DEFAULT 0, - withdraw TEXT NOT NULL, + tx_limit TEXT NOT NULL, + daily_limit TEXT NOT NULL, k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', @@ -31,9 +32,24 @@ async def m001_initial(db): id TEXT PRIMARY KEY, card_id TEXT NOT NULL, ip TEXT NOT NULL, + spent BOOL NOT NULL DEFAULT True, useragent TEXT, old_ctr INT NOT NULL DEFAULT 0, new_ctr INT NOT NULL DEFAULT 0, + amount INT NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + """ + CREATE TABLE boltcards.refunds ( + id TEXT PRIMARY KEY, + hit_id TEXT NOT NULL, + refund_amount INT NOT NULL, time TIMESTAMP NOT NULL DEFAULT """ + db.timestamp_now + """ diff --git a/lnbits/extensions/boltcards/models.py b/lnbits/extensions/boltcards/models.py index 6e199754..e272d2f9 100644 --- a/lnbits/extensions/boltcards/models.py +++ b/lnbits/extensions/boltcards/models.py @@ -10,7 +10,8 @@ class Card(BaseModel): card_name: str uid: str counter: int - withdraw: str + tx_limit: int + daily_limit: int k0: str k1: str k2: str @@ -20,12 +21,24 @@ class Card(BaseModel): otp: str time: int + def from_row(cls, row: Row) -> "Card": + return cls(**dict(row)) + + def lnurl(self, req: Request) -> Lnurl: + url = req.url_for( + "boltcard.lnurl_response", device_id=self.id, _external=True + ) + return lnurl_encode(url) + + async def lnurlpay_metadata(self) -> LnurlPayMetadata: + return LnurlPayMetadata(json.dumps([["text/plain", self.title]])) class CreateCardData(BaseModel): card_name: str = Query(...) uid: str = Query(...) counter: int = Query(0) - withdraw: str = Query(...) + tx_limit: int = Query(0) + daily_limit: int = Query(0) k0: str = Query(ZERO_KEY) k1: str = Query(ZERO_KEY) k2: str = Query(ZERO_KEY) @@ -33,12 +46,18 @@ class CreateCardData(BaseModel): prev_k1: str = Query(ZERO_KEY) prev_k2: str = Query(ZERO_KEY) - class Hit(BaseModel): id: str card_id: str ip: str + spent: bool useragent: str old_ctr: int new_ctr: int time: int + +class Refund(BaseModel): + id: str + hit_id: str + refund_amount: int + time: int \ No newline at end of file diff --git a/lnbits/extensions/boltcards/static/js/index.js b/lnbits/extensions/boltcards/static/js/index.js index d8beaa08..55f2d178 100644 --- a/lnbits/extensions/boltcards/static/js/index.js +++ b/lnbits/extensions/boltcards/static/js/index.js @@ -17,10 +17,14 @@ new Vue({ toggleAdvanced: false, cards: [], hits: [], - withdrawsOptions: [], cardDialog: { show: false, - data: {counter:1}, + data: { + counter:1, + k0: '', + k1: '', + k2: '', + card_name:''}, temp: {} }, cardsTable: { @@ -133,25 +137,6 @@ new Vue({ console.log(self.hits) }) }, - getWithdraws: function () { - var self = this - - LNbits.api - .request( - 'GET', - '/withdraw/api/v1/links?all_wallets=true', - this.g.user.wallets[0].inkey - ) - .then(function (response) { - self.withdrawsOptions = response.data.map(function (obj) { - return { - label: [obj.title, ' - ', obj.id].join(''), - value: obj.id - } - }) - console.log(self.withdraws) - }) - }, openQrCodeDialog(cardId) { var card = _.findWhere(this.cards, {id: cardId}) @@ -166,6 +151,7 @@ new Vue({ this.qrCodeDialog.show = true }, generateKeys: function () { + this.cardDialog.show = true const genRanHex = size => [...Array(size)] .map(() => Math.floor(Math.random() * 16).toString(16)) @@ -194,7 +180,6 @@ new Vue({ this.cardDialog.data = {} }, sendFormData: function () { - this.generateKeys() let wallet = _.findWhere(this.g.user.wallets, { id: this.cardDialog.data.wallet }) diff --git a/lnbits/extensions/boltcards/templates/boltcards/index.html b/lnbits/extensions/boltcards/templates/boltcards/index.html index 8b06b750..4c409387 100644 --- a/lnbits/extensions/boltcards/templates/boltcards/index.html +++ b/lnbits/extensions/boltcards/templates/boltcards/index.html @@ -5,7 +5,7 @@
- Add Card @@ -122,6 +122,45 @@ + + +
+
+
Refunds
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
@@ -148,15 +187,32 @@ label="Wallet *" > - - + +
+
+ +
+
+ +
+
+ + From the NFC 424 ntag card that will be loaded diff --git a/lnbits/extensions/boltcards/views_api.py b/lnbits/extensions/boltcards/views_api.py index 37a79626..ee91cffb 100644 --- a/lnbits/extensions/boltcards/views_api.py +++ b/lnbits/extensions/boltcards/views_api.py @@ -1,11 +1,3 @@ -# views_api.py is for you API endpoints that could be hit by another service - -# add your dependencies here - -# import httpx -# (use httpx just like requests, except instead of response.ok there's only the -# response.is_error that is its inverse) - import secrets from http import HTTPStatus @@ -15,7 +7,6 @@ from starlette.requests import Request from lnbits.core.crud import get_user from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key -from lnbits.extensions.withdraw import get_withdraw_link from . import boltcards_ext from .crud import ( @@ -127,90 +118,4 @@ async def api_hits( for card in cards: cards_ids.append(card.id) - return [hit.dict() for hit in await get_hits(cards_ids)] - - -# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 -@boltcards_ext.get("/api/v1/scan") -@boltcards_ext.get("/api/v1/scan/{card_uid}") -async def api_scan(p, c, request: Request, card_uid: str = None): - # some wallets send everything as lower case, no bueno - p = p.upper() - c = c.upper() - card = None - counter = b"" - - if not card_uid: - # since this route is common to all cards I don't know whitch 'meta key' to use - # so I try one by one until decrypted uid matches - for cand in await get_all_cards(): - if cand.k1: - try: - card_uid, counter = decryptSUN( - bytes.fromhex(p), bytes.fromhex(cand.k1) - ) - - if card_uid.hex().upper() == cand.uid.upper(): - card = cand - break - except: - continue - else: - 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."} - - if card == None: - return {"status": "ERROR", "reason": "Unknown card."} - - if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper(): - return {"status": "ERROR", "reason": "CMAC does not check."} - - ctr_int = int.from_bytes(counter, "little") - if ctr_int <= card.counter: - return {"status": "ERROR", "reason": "This link is already used."} - - await update_card_counter(ctr_int, card.id) - - # gathering some info for hit record - ip = request.client.host - if "x-real-ip" in request.headers: - ip = request.headers["x-real-ip"] - elif "x-forwarded-for" in request.headers: - ip = request.headers["x-forwarded-for"] - - agent = request.headers["user-agent"] if "user-agent" in request.headers else "" - - await create_hit(card.id, ip, agent, card.counter, ctr_int) - - link = await get_withdraw_link(card.withdraw, 0) - return link.lnurl_response(request) - - -# /boltcards/api/v1/auth?a=00000000000000000000000000000000 -@boltcards_ext.get("/api/v1/auth") -async def api_auth(a, request: Request): - if a == "00000000000000000000000000000000": - response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32} - return response - - card = await get_card_by_otp(a) - - if not card: - raise HTTPException( - detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND - ) - - new_otp = secrets.token_hex(16) - print(card.otp) - print(new_otp) - await update_card_otp(new_otp, card.id) - - response = {"k0": card.k0, "k1": card.k1, "k2": card.k2} - - return response + return [hit.dict() for hit in await get_hits(cards_ids)] \ No newline at end of file