feat: update to lnbits 1.0.0 (#41)

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
This commit is contained in:
dni ⚡ 2024-10-23 15:45:23 +02:00 committed by GitHub
commit 7f8b269ecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1513 additions and 1457 deletions

View file

@ -2,7 +2,7 @@
"name": "Bolt Cards",
"short_description": "Self custody Bolt Cards with one time LNURLw",
"tile": "/boltcards/static/image/boltcard.png",
"min_lnbits_version": "0.12.5",
"min_lnbits_version": "1.0.0",
"contributors": [
{
"name": "dni",

230
crud.py
View file

@ -1,6 +1,6 @@
import secrets
from datetime import datetime
from typing import List, Optional
from typing import Optional
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
@ -31,175 +31,157 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card:
k2,
otp
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (
:id, :uid, :external_id, :wallet, :card_name, :counter,
:tx_limit, :daily_limit, :enable, :k0, :k1, :k2, :otp
)
""",
(
card_id,
data.uid.upper(),
extenal_id,
wallet_id,
data.card_name,
data.counter,
data.tx_limit,
data.daily_limit,
True,
data.k0,
data.k1,
data.k2,
secrets.token_hex(16),
),
{
"id": card_id,
"uid": data.uid.upper(),
"external_id": extenal_id,
"wallet": wallet_id,
"card_name": data.card_name,
"counter": data.counter,
"tx_limit": data.tx_limit,
"daily_limit": data.daily_limit,
"enable": True,
"k0": data.k0,
"k1": data.k1,
"k2": data.k2,
"otp": secrets.token_hex(16),
},
)
card = await get_card(card_id)
assert card, "Newly created card couldn't be retrieved"
return card
async def update_card(card_id: str, **kwargs) -> Optional[Card]:
if "is_unique" in kwargs:
kwargs["is_unique"] = int(kwargs["is_unique"])
if "uid" in kwargs:
kwargs["uid"] = kwargs["uid"].upper()
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE boltcards.cards SET {q} WHERE id = ?",
(*kwargs.values(), card_id),
async def update_card(card_id: str, data: CreateCardData) -> Card:
card = Card(
id=card_id,
**data.dict(),
)
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
return Card(**row) if row else None
await db.update("boltcards.cards", card)
return card
async def get_cards(wallet_ids: List[str]) -> List[Card]:
async def get_cards(wallet_ids: list[str]) -> list[Card]:
if len(wallet_ids) == 0:
return []
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})", (*wallet_ids,)
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
return await db.fetchall(
f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})",
model=Card,
)
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:
return None
card = dict(**row)
return Card.parse_obj(card)
return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE id = :id",
{"id": card_id},
Card,
)
async def get_card_by_uid(card_uid: str) -> Optional[Card]:
row = await db.fetchone(
"SELECT * FROM boltcards.cards WHERE uid = ?", (card_uid.upper(),)
return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE uid = :uid",
{"uid": card_uid.upper()},
Card,
)
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_external_id(external_id: str) -> Optional[Card]:
row = await db.fetchone(
"SELECT * FROM boltcards.cards WHERE external_id = ?", (external_id.lower(),)
return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE external_id = :ext_id",
{"ext_id": external_id.lower()},
Card,
)
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_otp(otp: str) -> Optional[Card]:
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE otp = ?", (otp,))
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE otp = :otp",
{"otp": otp},
Card,
)
async def delete_card(card_id: str) -> None:
# Delete cards
await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,))
await db.execute("DELETE FROM boltcards.cards WHERE id = :id", {"id": card_id})
# Delete hits
hits = await get_hits([card_id])
for hit in hits:
await db.execute("DELETE FROM boltcards.hits WHERE id = ?", (hit.id,))
await db.execute("DELETE FROM boltcards.hits WHERE id = :id", {"id": hit.id})
# Delete refunds
refunds = await get_refunds([hit.id])
for refund in refunds:
await db.execute(
"DELETE FROM boltcards.refunds WHERE id = ?", (refund.hit_id,)
"DELETE FROM boltcards.refunds WHERE id = :id", {"id": refund.id}
)
async def update_card_counter(counter: int, card_id: str):
await db.execute(
"UPDATE boltcards.cards SET counter = ? WHERE id = ?",
(counter, card_id),
"UPDATE boltcards.cards SET counter = :counter WHERE id = :id",
{"counter": counter, "id": card_id},
)
async def enable_disable_card(enable: bool, card_id: str) -> Optional[Card]:
await db.execute(
"UPDATE boltcards.cards SET enable = ? WHERE id = ?",
(enable, card_id),
"UPDATE boltcards.cards SET enable = :enable WHERE id = :id",
{"enable": enable, "id": card_id},
)
return await get_card(card_id)
async def update_card_otp(otp: str, card_id: str):
await db.execute(
"UPDATE boltcards.cards SET otp = ? WHERE id = ?",
(otp, card_id),
"UPDATE boltcards.cards SET otp = :otp WHERE id = :id",
{"otp": otp, "id": card_id},
)
async def get_hit(hit_id: str) -> Optional[Hit]:
row = await db.fetchone("SELECT * FROM boltcards.hits WHERE id = ?", (hit_id,))
if not row:
return None
hit = dict(**row)
return Hit.parse_obj(hit)
return await db.fetchone(
"SELECT * FROM boltcards.hits WHERE id = :id",
{"id": hit_id},
Hit,
)
async def get_hits(cards_ids: List[str]) -> List[Hit]:
async def get_hits(cards_ids: list[str]) -> list[Hit]:
if len(cards_ids) == 0:
return []
q = ",".join(["?"] * len(cards_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,)
q = ",".join([f"'{card_id}'" for card_id in cards_ids])
return await db.fetchall(
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})",
model=Hit,
)
return [Hit(**row) for row in rows]
async def get_hits_today(card_id: str) -> List[Hit]:
async def get_hits_today(card_id: str) -> list[Hit]:
rows = await db.fetchall(
"SELECT * FROM boltcards.hits WHERE card_id = ?",
(card_id,),
"SELECT * FROM boltcards.hits WHERE card_id = :id",
{"id": card_id},
Hit,
)
updatedrow = []
for row in rows:
if datetime.now().date() == datetime.fromtimestamp(row.time).date():
updatedrow.append(row)
for hit in rows:
if datetime.now().date() == hit.time.date():
updatedrow.append(hit)
return [Hit(**row) for row in updatedrow]
return updatedrow
async def spend_hit(card_id: str, amount: int):
await db.execute(
"UPDATE boltcards.hits SET spent = ?, amount = ? WHERE id = ?",
(True, amount, card_id),
"UPDATE boltcards.hits SET spent = :spent, amount = :amount WHERE id = :id",
{"spent": True, "amount": amount, "id": card_id},
)
return await get_hit(card_id)
@ -218,18 +200,18 @@ async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:
new_ctr,
amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
VALUES (:id, :card_id, :ip, :spent, :useragent, :old_ctr, :new_ctr, :amount)
""",
(
hit_id,
card_id,
ip,
False,
useragent,
old_ctr,
new_ctr,
0,
),
{
"id": hit_id,
"card_id": card_id,
"ip": ip,
"spent": False,
"useragent": useragent,
"old_ctr": old_ctr,
"new_ctr": new_ctr,
"amount": 0,
},
)
hit = await get_hit(hit_id)
assert hit, "Newly recorded hit couldn't be retrieved"
@ -245,13 +227,13 @@ async def create_refund(hit_id, refund_amount) -> Refund:
hit_id,
refund_amount
)
VALUES (?, ?, ?)
VALUES (:id, :hit_id, :refund_amount)
""",
(
refund_id,
hit_id,
refund_amount,
),
{
"id": refund_id,
"hit_id": hit_id,
"refund_amount": refund_amount,
},
)
refund = await get_refund(refund_id)
assert refund, "Newly recorded hit couldn't be retrieved"
@ -259,22 +241,18 @@ async def create_refund(hit_id, refund_amount) -> Refund:
async def get_refund(refund_id: str) -> Optional[Refund]:
row = await db.fetchone(
"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id,)
return await db.fetchone(
"SELECT * FROM boltcards.refunds WHERE id = :id",
{"id": refund_id},
Refund,
)
if not row:
return None
refund = dict(**row)
return Refund.parse_obj(refund)
async def get_refunds(hits_ids: List[str]) -> List[Refund]:
async def get_refunds(hits_ids: list[str]) -> list[Refund]:
if len(hits_ids) == 0:
return []
q = ",".join(["?"] * len(hits_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,)
q = ",".join([f"'{hit_id}'" for hit_id in hits_ids])
return await db.fetchall(
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})",
model=Refund,
)
return [Refund(**row) for row in rows]

View file

@ -1,5 +1,5 @@
import json
from sqlite3 import Row
from datetime import datetime
from fastapi import Query, Request
from lnurl import Lnurl
@ -17,8 +17,10 @@ class Card(BaseModel):
uid: str
external_id: str
counter: int
tx_limit: int
daily_limit: int
# TODO: database column is TEXT should be INT
tx_limit: str
# TODO: database column is TEXT should be INT
daily_limit: str
enable: bool
k0: str
k1: str
@ -27,11 +29,7 @@ class Card(BaseModel):
prev_k1: str
prev_k2: str
otp: str
time: int
@classmethod
def from_row(cls, row: Row) -> "Card":
return cls(**dict(row))
time: datetime
def lnurl(self, req: Request) -> Lnurl:
url = str(
@ -67,19 +65,11 @@ class Hit(BaseModel):
old_ctr: int
new_ctr: int
amount: int
time: int
@classmethod
def from_row(cls, row: Row) -> "Hit":
return cls(**dict(row))
time: datetime
class Refund(BaseModel):
id: str
hit_id: str
refund_amount: int
time: int
@classmethod
def from_row(cls, row: Row) -> "Refund":
return cls(**dict(row))
time: datetime

2236
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ authors = ["Alan Bits <alan@lnbits.com>"]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = "*"
lnbits = {version = "*", allow-prereleases = true}
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
@ -14,7 +14,7 @@ pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
ruff = "^0.6.3"
[build-system]
requires = ["poetry-core>=1.0.0"]

View file

@ -1,21 +1,15 @@
Vue.component(VueQrcode.name, VueQrcode)
const mapCards = obj => {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.date = Quasar.date.formatDate(new Date(obj.time), 'YYYY-MM-DD HH:mm')
return obj
}
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data: function () {
data() {
return {
toggleAdvanced: false,
nfcTagReading: false,
disableNfcButton: true,
lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`,
cards: [],
hits: [],
@ -155,107 +149,77 @@ new Vue({
}
},
methods: {
readNfcTag: function () {
try {
const self = this
if (typeof NDEFReader == 'undefined') {
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
console.log('All NFC Read operations have been aborted.')
}
this.nfcTagReading = true
this.$q.notify({
message: 'Tap your NFC tag to copy its UID here.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
self.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message, serialNumber}) => {
//Decode NDEF data from tag
var self = this
self.cardDialog.data.uid = serialNumber
.toUpperCase()
.replaceAll(':', '')
this.$q.notify({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
} catch (error) {
this.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
readNfcTag() {
const ndef = new NDEFReader()
const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
console.log('All NFC Read operations have been aborted.')
}
},
getCards: function () {
var self = this
Quasar.Notify.create({
message: 'Tap your NFC tag to copy its UID here.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
this.disableNfcButton = false
Quasar.Notify.create({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message, serialNumber}) => {
const uid = serialNumber.toUpperCase().replaceAll(':', '')
//Decode NDEF data from tag
this.cardDialog.data.uid = uid
Quasar.Notify.create({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
},
getCards() {
LNbits.api
.request(
'GET',
'/boltcards/api/v1/cards?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.cards = response.data.map(function (obj) {
.then(response => {
this.cards = response.data.map(function (obj) {
return mapCards(obj)
})
})
.then(function () {
self.getHits()
.then(() => {
this.getHits()
})
},
getHits: function () {
var self = this
getHits() {
LNbits.api
.request(
'GET',
'/boltcards/api/v1/hits?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.hits = response.data.map(function (obj) {
obj.card_name = self.cards.find(d => d.id == obj.card_id).card_name
.then(response => {
this.hits = response.data.map(obj => {
obj.card_name = this.cards.find(d => d.id == obj.card_id).card_name
return mapCards(obj)
})
})
},
getRefunds: function () {
var self = this
getRefunds() {
LNbits.api
.request(
'GET',
'/boltcards/api/v1/refunds?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.refunds = response.data.map(function (obj) {
.then(response => {
this.refunds = response.data.map(obj => {
return mapCards(obj)
})
})
@ -287,12 +251,11 @@ new Vue({
this.qrCodeDialog.wipe = wipe
this.qrCodeDialog.show = true
},
addCardOpen: function () {
addCardOpen() {
this.cardDialog.show = true
this.generateKeys()
},
generateKeys: function () {
var self = this
generateKeys() {
const genRandomHexBytes = size =>
crypto
.getRandomValues(new Uint8Array(size))
@ -302,22 +265,22 @@ new Vue({
typeof this.cardDialog.data.card_name === 'string' &&
this.cardDialog.data.card_name.search('debug') > -1
self.cardDialog.data.k0 = debugcard
this.cardDialog.data.k0 = debugcard
? '11111111111111111111111111111111'
: genRandomHexBytes(16)
self.cardDialog.data.k1 = debugcard
this.cardDialog.data.k1 = debugcard
? '22222222222222222222222222222222'
: genRandomHexBytes(16)
self.cardDialog.data.k2 = debugcard
this.cardDialog.data.k2 = debugcard
? '33333333333333333333333333333333'
: genRandomHexBytes(16)
},
closeFormDialog: function () {
closeFormDialog() {
this.cardDialog.data = {}
},
sendFormData: function () {
sendFormData() {
let wallet = _.findWhere(this.g.user.wallets, {
id: this.cardDialog.data.wallet
})
@ -328,21 +291,17 @@ new Vue({
this.createCard(wallet, data)
}
},
createCard: function (wallet, data) {
var self = this
createCard(wallet, data) {
LNbits.api
.request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data)
.then(function (response) {
self.cards.push(mapCards(response.data))
self.cardDialog.show = false
self.cardDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
.then(response => {
this.cards.push(mapCards(response.data))
this.cardDialog.show = false
this.cardDialog.data = {}
})
.catch(LNbits.utils.notifyApiError)
},
updateCardDialog: function (formId) {
updateCardDialog(formId) {
var card = _.findWhere(this.cards, {id: formId})
this.cardDialog.data = _.clone(card)
@ -352,9 +311,7 @@ new Vue({
this.cardDialog.show = true
},
updateCard: function (wallet, data) {
var self = this
updateCard(wallet, data) {
if (
this.cardDialog.temp.k0 != data.k0 ||
this.cardDialog.temp.k1 != data.k1 ||
@ -372,21 +329,20 @@ new Vue({
wallet.adminkey,
data
)
.then(function (response) {
self.cards = _.reject(self.cards, function (obj) {
.then(response => {
this.cards = _.reject(this.cards, function (obj) {
return obj.id == data.id
})
self.cards.push(mapCards(response.data))
self.cardDialog.show = false
self.cardDialog.data = {}
this.cards.push(mapCards(response.data))
this.cardDialog.show = false
this.cardDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
enableCard: function (wallet, card_id, enable) {
var self = this
let fullWallet = _.findWhere(self.g.user.wallets, {
enableCard(wallet, card_id, enable) {
let fullWallet = _.findWhere(this.g.user.wallets, {
id: wallet
})
LNbits.api
@ -395,19 +351,18 @@ new Vue({
'/boltcards/api/v1/cards/enable/' + card_id + '/' + enable,
fullWallet.adminkey
)
.then(function (response) {
.then(response => {
console.log(response.data)
self.cards = _.reject(self.cards, function (obj) {
this.cards = _.reject(this.cards, function (obj) {
return obj.id == response.data.id
})
self.cards.push(mapCards(response.data))
this.cards.push(mapCards(response.data))
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteCard: function (cardId) {
let self = this
deleteCard(cardId) {
let cards = _.findWhere(this.cards, {id: cardId})
Quasar.utils.exportFile(
@ -420,15 +375,15 @@ new Vue({
.confirmDialog(
"Are you sure you want to delete this card? Without access to the card keys you won't be able to reset them in the future!"
)
.onOk(function () {
.onOk(() => {
LNbits.api
.request(
'DELETE',
'/boltcards/api/v1/cards/' + cardId,
_.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey
_.findWhere(this.g.user.wallets, {id: cards.wallet}).adminkey
)
.then(function (response) {
self.cards = _.reject(self.cards, function (obj) {
.then(response => {
this.cards = _.reject(this.cards, function (obj) {
return obj.id == cardId
})
})
@ -437,20 +392,39 @@ new Vue({
})
})
},
exportCardsCSV: function () {
exportCardsCSV() {
LNbits.utils.exportCSV(this.cardsTable.columns, this.cards)
},
exportHitsCSV: function () {
exportHitsCSV() {
LNbits.utils.exportCSV(this.hitsTable.columns, this.hits)
},
exportRefundsCSV: function () {
exportRefundsCSV() {
LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds)
}
},
created: function () {
created() {
if (this.g.user.wallets.length) {
this.getCards()
this.getRefunds()
}
try {
if (typeof NDEFReader == 'undefined') {
throw {
toString() {
return 'NFC not supported on this device or browser.'
}
}
}
this.disableNfcButton = false
Quasar.Notify.create({
type: 'positive',
message: 'NFC is supported on this device. You can now read NFC tags.'
})
} catch (error) {
Quasar.Notify.create({
type: 'negative',
message: error ? error.toString() : 'An unexpected error has occurred.'
})
}
}
})

View file

@ -1,8 +1,7 @@
import asyncio
from lnbits.core.crud import update_payment_extra
from lnbits.core.crud import update_payment
from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import create_refund, get_hit
@ -10,7 +9,7 @@ from .crud import create_refund, get_hit
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
register_invoice_listener(invoice_queue, "ext_boltcards")
while True:
payment = await invoice_queue.get()
@ -19,7 +18,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra.get("refund"):
if not payment.extra or not payment.extra.get("refund"):
return
if payment.extra.get("wh_status"):
@ -31,4 +30,4 @@ async def on_invoice_paid(payment: Payment) -> None:
if hit:
await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000))
payment.extra["wh_status"] = 1
await update_payment_extra(payment.payment_hash, payment.extra)
await update_payment(payment)

View file

@ -74,20 +74,19 @@ card.card_name }{% endblock %} {% block page %}
{% endblock %}{% block scripts %}
<script>
const mapHits = obj => {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
obj.date = Quasar.date.formatDate(
new Date(obj.time),
'YYYY-MM-DD HH:mm'
)
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
window.app = Vue.createApp({
el: '#vue',
delimiters: ['${', '}'],
mixins: [windowMixin],
data: function () {
data() {
return {
card: null,
hits: null,
@ -120,11 +119,11 @@ card.card_name }{% endblock %} {% block page %}
}
},
created() {
this.card = JSON.parse('{{ card | tojson}}')
let hits = JSON.parse('{{ hits | tojson}}')
let refunds = JSON.parse('{{ refunds | tojson}}')
this.card = JSON.parse({{ card | tojson }})
const hits = {{ hits | tojson | safe }}
this.hits = hits.map(JSON.parse).map(mapHits)
const refunds = {{ refunds | tojson | safe }}
this.refunds = refunds || []
this.hits = hits.map(mapHits)
},
computed: {
enabled() {
@ -133,8 +132,8 @@ card.card_name }{% endblock %} {% block page %}
},
methods: {
copyText(text, message, position) {
Quasar.utils.copyToClipboard(text).then(() => {
this.$q.notify({
Quasar.copyToClipboard(text).then(() => {
Quasar.Notify.create({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
})

View file

@ -34,19 +34,21 @@
<q-table
dense
flat
:data="cards"
:rows="cards"
row-key="id"
:columns="cardsTable.columns"
:pagination.sync="cardsTable.pagination"
v-model:pagination="cardsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th>
@ -78,9 +80,12 @@
<q-tooltip>Card Stats</q-tooltip>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.value"
></q-td>
<q-td auto-width>
<q-btn
v-if="props.row.enable"
@ -125,7 +130,6 @@
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
@ -144,27 +148,31 @@
<q-table
dense
flat
:data="hits"
:rows="hits"
row-key="id"
:columns="hitsTable.columns"
:pagination.sync="hitsTable.pagination"
v-model:pagination="hitsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.value"
></q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
@ -183,27 +191,31 @@
<q-table
dense
flat
:data="refunds"
:rows="refunds"
row-key="id"
:columns="refundsTable.columns"
:pagination.sync="refundsTable.pagination"
v-model:pagination="refundsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.value"
></q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
@ -240,7 +252,7 @@
filled
dense
emit-value
v-model.trim="cardDialog.data.tx_limit"
v-model="cardDialog.data.tx_limit"
type="number"
label="Max transaction (sats)"
class="q-pr-sm"
@ -251,7 +263,7 @@
filled
dense
emit-value
v-model.trim="cardDialog.data.daily_limit"
v-model="cardDialog.data.daily_limit"
type="number"
label="Daily limit (sats)"
></q-input>
@ -261,7 +273,7 @@
filled
dense
emit-value
v-model.trim="cardDialog.data.card_name"
v-model="cardDialog.data.card_name"
type="text"
label="Card name "
>
@ -272,7 +284,7 @@
filled
dense
emit-value
v-model.trim="cardDialog.data.uid"
v-model="cardDialog.data.uid"
type="text"
label="Card UID "
>
@ -281,36 +293,34 @@
<div class="col-2 q-pl-sm">
<q-btn
outline
disable
color="grey"
icon="nfc"
:disable="nfcTagReading"
:disable="disableNfcButton"
@click="readNfcTag()"
>
<q-tooltip>Tap card to scan UID</q-tooltip>
</q-btn>
</div>
</div>
<q-toggle
v-model="toggleAdvanced"
label="Show advanced options"
></q-toggle>
<div v-show="toggleAdvanced" class="q-gutter-y-md">
<q-input
filled
dense
v-model.trim="cardDialog.data.k0"
v-model="cardDialog.data.k0"
type="text"
label="Card Auth key (K0)"
hint="Used to authentificate with the card (16 bytes in HEX). "
@randomkey
>
</q-input>
></q-input>
<q-input
filled
dense
v-model.trim="cardDialog.data.k1"
v-model="cardDialog.data.k1"
type="text"
label="Card Meta key (K1)"
hint="Used for encypting of the message (16 bytes in HEX)."
@ -318,12 +328,11 @@
<q-input
filled
dense
v-model.trim="cardDialog.data.k2"
v-model="cardDialog.data.k2"
type="text"
label="Card File key (K2)"
hint="Used for CMAC of the message (16 bytes in HEX)."
>
</q-input>
></q-input>
<q-input
filled
dense
@ -339,8 +348,7 @@
unelevated
color="primary"
class="q-ml-auto"
v-on:click="generateKeys"
v-on:click.right="debugKeys"
@click="generateKeys"
>Generate keys</q-btn
>
</div>
@ -370,19 +378,12 @@
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<div class="col q-mt-lg text-center">
<q-responsive
:ratio="1"
class="q-mx-xl q-mb-md"
<lnbits-qrcode
:value="qrCodeDialog.data.link"
class="rounded-borders"
v-show="!qrCodeDialog.wipe"
>
<qrcode
:value="qrCodeDialog.data.link"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
></lnbits-qrcode>
<p class="text-center" v-show="!qrCodeDialog.wipe">
(QR for <strong>create</strong> the card in
<a
@ -393,17 +394,11 @@
>Boltcard NFC Card Creator</a
>)
</p>
<q-responsive
:ratio="1"
class="q-mx-xl q-mb-md"
<lnbits-qrcode
:value="qrCodeDialog.data_wipe"
class="rounded-borders"
v-show="qrCodeDialog.wipe"
>
<qrcode
:value="qrCodeDialog.data_wipe"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
></lnbits-qrcode>
<p class="text-center" v-show="qrCodeDialog.wipe">
(QR for <strong>wipe</strong> the card in
<a
@ -430,12 +425,18 @@
/>
</div>
<p style="word-break: break-all">
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br />
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br />
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br />
<strong>Lock key (K0):</strong> {{ qrCodeDialog.data.k0 }}<br />
<strong>Meta key (K1 & K3):</strong> {{ qrCodeDialog.data.k1 }}<br />
<strong>File key (K2 & K4):</strong> {{ qrCodeDialog.data.k2 }}<br />
<strong>Name: </strong><span v-text="qrCodeDialog.data.name"></span
><br />
<strong>UID: </strong> <span v-text="qrCodeDialog.data.uid"></span
><br />
<strong>External ID:</strong>
<span v-text="qrCodeDialog.data.external_id"></span><br />
<strong>Lock key (K0):</strong>
<span v-text="qrCodeDialog.data.k0"></span><br />
<strong>Meta key (K1 & K3):</strong>
<span v-text="qrCodeDialog.data.k1"></span><br />
<strong>File key (K2 & K4):</strong>
<span v-text="qrCodeDialog.data.k2"></span><br />
</p>
<p>
Always backup all keys that you're trying to write on the card. Without
@ -472,7 +473,6 @@
>
<q-tooltip>Backup the keys, or wipe the card first!</q-tooltip>
</q-btn>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
@ -481,5 +481,5 @@
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/boltcards/static/js/index.js"></script>
<script src="{{ static_url_for('boltcards/static', path='js/index.js') }}"></script>
{% endblock %}

View file

@ -1,16 +1,13 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from .crud import get_card_by_external_id, get_hits, get_refunds
templates = Jinja2Templates(directory="templates")
boltcards_generic_router = APIRouter()
@ -21,7 +18,7 @@ def boltcards_renderer():
@boltcards_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return boltcards_renderer().TemplateResponse(
"boltcards/index.html", {"request": request, "user": user.dict()}
"boltcards/index.html", {"request": request, "user": user.json()}
)
@ -32,15 +29,11 @@ async def display(request: Request, card_id: str):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Card does not exist."
)
hits = [hit.dict() for hit in await get_hits([card.id])]
refunds = [
refund.hit_id for refund in await get_refunds([hit["id"] for hit in hits])
]
card_dict = card.dict()
# Remove wallet id from card dict
del card_dict["wallet"]
hits = await get_hits([card.id])
hits_json = [hit.json() for hit in hits]
refunds = [refund.hit_id for refund in await get_refunds([hit.id for hit in hits])]
card_json = card.json(exclude={"wallet"})
return boltcards_renderer().TemplateResponse(
"boltcards/display.html",
{"request": request, "card": card_dict, "hits": hits, "refunds": refunds},
{"request": request, "card": card_json, "hits": hits_json, "refunds": refunds},
)

View file

@ -3,7 +3,7 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Query
from lnbits.core.crud import get_user
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import get_key_type, require_admin_key
from lnbits.decorators import require_admin_key, require_invoice_key
from .crud import (
create_card,
@ -16,22 +16,22 @@ from .crud import (
get_refunds,
update_card,
)
from .models import Card, CreateCardData
from .models import Card, CreateCardData, Hit, Refund
boltcards_api_router = APIRouter()
@boltcards_api_router.get("/api/v1/cards")
async def api_cards(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = False
):
wallet_ids = [g.wallet.id]
key_info: WalletTypeInfo = Depends(require_invoice_key), all_wallets: bool = False
) -> list[Card]:
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(g.wallet.user)
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
return [card.dict() for card in await get_cards(wallet_ids)]
return await get_cards(wallet_ids)
def validate_card(data: CreateCardData):
@ -146,12 +146,13 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi
@boltcards_api_router.get("/api/v1/hits")
async def api_hits(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
key_info: WalletTypeInfo = Depends(require_invoice_key),
all_wallets: bool = Query(False),
) -> list[Hit]:
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(g.wallet.user)
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
cards = await get_cards(wallet_ids)
@ -159,17 +160,18 @@ async def api_hits(
for card in cards:
cards_ids.append(card.id)
return [hit.dict() for hit in await get_hits(cards_ids)]
return await get_hits(cards_ids)
@boltcards_api_router.get("/api/v1/refunds")
async def api_refunds(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
key_info: WalletTypeInfo = Depends(require_invoice_key),
all_wallets: bool = Query(False),
) -> list[Refund]:
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(g.wallet.user)
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
cards = await get_cards(wallet_ids)
@ -181,4 +183,4 @@ async def api_refunds(
for hit in hits:
hits_ids.append(hit.id)
return [refund.dict() for refund in await get_refunds(hits_ids)]
return await get_refunds(hits_ids)

View file

@ -69,8 +69,8 @@ async def api_scan(p, c, request: Request, external_id: str):
hits_amount = 0
for hit in todays_hits:
hits_amount = hits_amount + hit.amount
if hits_amount > card.daily_limit:
hits_amount += hit.amount
if hits_amount > int(card.daily_limit):
return {"status": "ERROR", "reason": "Max daily limit spent."}
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
@ -88,7 +88,7 @@ async def api_scan(p, c, request: Request, external_id: str):
"callback": str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)),
"k1": hit.id,
"minWithdrawable": 1 * 1000,
"maxWithdrawable": card.tx_limit * 1000,
"maxWithdrawable": int(card.tx_limit) * 1000,
"defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay_bech32})",
"payLink": lnurlpay_nonbech32_lud17, # LUD-19 compatibility
}
@ -135,7 +135,7 @@ async def lnurl_callback(
await pay_invoice(
wallet_id=card.wallet,
payment_request=pr,
max_sat=card.tx_limit,
max_sat=int(card.tx_limit),
extra={"tag": "boltcards", "hit": hit.id},
)
return {"status": "OK"}
@ -201,14 +201,13 @@ async def lnurlp_response(req: Request, hit_id: str):
"callback": str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)),
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
"minSendable": 1 * 1000,
"maxSendable": card.tx_limit * 1000,
"maxSendable": int(card.tx_limit) * 1000,
}
return json.dumps(pay_response)
@boltcards_lnurl_router.get(
"/api/v1/lnurlp/cb/{hit_id}",
response_class=HTMLResponse,
name="boltcards.lnurlp_callback",
)
async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
@ -219,7 +218,7 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
if not hit:
return {"status": "ERROR", "reason": "LNURL-pay record not found."}
_, payment_request = await create_invoice(
payment = await create_invoice(
wallet_id=card.wallet,
amount=int(int(amount) / 1000),
memo=f"Refund {hit_id}",
@ -229,6 +228,4 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
extra={"refund": hit_id},
)
pay_response = {"pr": payment_request, "routes": []}
return json.dumps(pay_response)
return {"pr": payment.bolt11, "routes": []}