new extension

just proof of concept
This commit is contained in:
iWarpBTC 2022-06-13 21:08:06 +02:00 committed by Lee Salminen
parent c32ff1de59
commit 4fab2d3101
11 changed files with 780 additions and 0 deletions

View File

@ -0,0 +1,11 @@
<h1>boltcards Extension</h1>
<h2>*tagline*</h2>
This is an boltcards extension to help you organise and build you own.
Try to include an image
<img src="https://i.imgur.com/9i4xcQB.png">
<h2>If your extension has API endpoints, include useful ones here</h2>
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/boltcards -d '{"amount":"100","memo":"boltcards"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>

View File

@ -0,0 +1,19 @@
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_boltcards")
boltcards_ext: APIRouter = APIRouter(
prefix="/boltcards",
tags=["boltcards"]
)
def boltcards_renderer():
return template_renderer(["lnbits/extensions/boltcards/templates"])
from .views import * # noqa
from .views_api import * # noqa

View File

@ -0,0 +1,6 @@
{
"name": "Bolt Cards",
"short_description": "Self custody Bolt Cards with one time LNURLw",
"icon": "payment",
"contributors": ["iwarpbtc"]
}

View File

@ -0,0 +1,91 @@
from optparse import Option
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Card, CreateCardData
async def create_card(
data: CreateCardData, wallet_id: str
) -> Card:
card_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO boltcards.cards (
id,
wallet,
card_name,
uid,
counter,
withdraw,
file_key,
meta_key
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
card_id,
wallet_id,
data.name,
data.uid,
data.counter,
data.withdraw,
data.file_key,
data.meta_key,
),
)
link = await get_card(card_id, 0)
assert link, "Newly created card couldn't be retrieved"
return link
async def update_card(card_id: str, **kwargs) -> Optional[Card]:
if "is_unique" in kwargs:
kwargs["is_unique"] = int(kwargs["is_unique"])
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),
)
row = await db.fetchone(
"SELECT * FROM boltcards.cards WHERE id = ?", (card_id,)
)
return Card(**row) if row else None
async def get_cards(wallet_ids: Union[str, List[str]]) -> List[Card]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})", (*wallet_ids,)
)
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, id_is_uid: bool=False) -> Optional[Card]:
sql = "SELECT * FROM boltcards.cards WHERE {} = ?".format("uid" if id_is_uid else "id")
row = await db.fetchone(
sql, card_id,
)
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def delete_card(card_id: str) -> None:
await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,))
async def update_card_counter(counter: int, id: str):
await db.execute(
"UPDATE boltcards.cards SET counter = ? WHERE id = ?",
(counter, id),
)

View File

@ -0,0 +1,20 @@
from lnbits.helpers import urlsafe_short_hash
async def m001_initial(db):
await db.execute(
"""
CREATE TABLE boltcards.cards (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
card_name TEXT NOT NULL,
uid TEXT NOT NULL,
counter INT NOT NULL DEFAULT 0,
withdraw TEXT NOT NULL,
file_key TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
meta_key TEXT NOT NULL DEFAULT '',
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)

View File

@ -0,0 +1,21 @@
from pydantic import BaseModel
from fastapi.params import Query
class Card(BaseModel):
id: str
wallet: str
card_name: str
uid: str
counter: int
withdraw: str
file_key: str
meta_key: str
time: int
class CreateCardData(BaseModel):
card_name: str = Query(...)
uid: str = Query(...)
counter: str = Query(...)
withdraw: str = Query(...)
file_key: str = Query(...)
meta_key: str = Query(...)

View File

@ -0,0 +1,31 @@
from typing import Tuple
from Cryptodome.Hash import CMAC
from Cryptodome.Cipher import AES
SV2 = "3CC300010080"
def myCMAC(key: bytes, msg: bytes=b'') -> bytes:
cobj = CMAC.new(key, ciphermod=AES)
if msg != b'':
cobj.update(msg)
return cobj.digest()
def decryptSUN(sun: bytes, key: bytes) -> Tuple[bytes, bytes]:
IVbytes = b"\x00" * 16
cipher = AES.new(key, AES.MODE_CBC, IVbytes)
sun_plain = cipher.decrypt(sun)
UID = sun_plain[1:8]
counter = sun_plain[8:11]
return UID, counter
def getSunMAC(UID: bytes, counter: bytes, key: bytes) -> bytes:
sv2prefix = bytes.fromhex(SV2)
sv2bytes = sv2prefix + UID + counter
mac1 = myCMAC(key, sv2bytes)
mac2 = myCMAC(mac1)
return mac2[1::2]

View File

@ -0,0 +1,27 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About Bolt Cards"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
Be your own card association
</h5>
<p>
Manage your Bolt Cards self custodian way<br />
<a
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/boltcards"
>More details</a
>
<br />
<small>
Created by,
<a href="https://twitter.com/btcslovnik">iWarp</a></small
>
</p>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@ -0,0 +1,375 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="cardDialog.show = true"
>Add Card</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Cards</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCardsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="cards"
row-key="id"
:columns="cardsTable.columns"
:pagination.sync="cardsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<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 auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.displayUrl"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateCardDialog(props.row.id)"
icon="edit"
color="light-blue"
>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteCard(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Bolt Cards extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "boltcards/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="cardDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="cardDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-select
filled
dense
emit-value
v-model="cardDialog.data.withdraw"
:options="withdrawsOptions"
label="Withdraw link *"
>
</q-select>
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.card_name"
type="text"
label="Card name "
><q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>The domain to use ex: "example.com"</q-tooltip
></q-input
>
<q-input
filled
dense
bottom-slots
v-model.trim="cardDialog.data.uid"
type="text"
label="Card UID"
>
</q-input>
<q-input
filled
dense
v-model.trim="cardDialog.data.file_key"
type="text"
label="Card File key"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>Create a "Edit zone DNS" API token in cloudflare</q-tooltip
>
</q-input>
<q-input
filled
dense
v-model.trim="cardDialog.data.meta_key"
type="text"
label="Card Meta key"
hint="A URL to be called whenever this link receives a payment."
></q-input>
<q-input
filled
dense
v-model.number="cardDialog.data.counter"
type="number"
label="Initial counter"
><q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>How much to charge per day</q-tooltip
></q-input
>
<div class="row q-mt-lg">
<q-btn
v-if="cardDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Form</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="cardDialog.data.uid == null"
type="submit"
>Create Card</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
const mapCards = obj => {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.displayUrl = ['/boltcards/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
cards: [],
withdrawsOptions: [],
cardDialog: {
show: false,
data: {}
},
cardsTable: {
columns: [
{
name: 'card_name',
align: 'left',
label: 'Card name',
field: 'card_name'
},
{
name: 'counter',
align: 'left',
label: 'Counter',
field: 'counter'
},
{
name: 'withdraw',
align: 'left',
label: 'Withdraw ID',
field: 'withdraw'
},
],
pagination: {
rowsPerPage: 10
}
}
}
},
methods: {
getCards: function () {
var self = this
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) {
return mapCards(obj)
})
console.log(self.cards)
})
},
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)
})
},
sendFormData: function () {
let wallet = _.findWhere(this.g.user.wallets, {
id: this.cardDialog.data.wallet
})
let data = this.cardDialog.data
if (data.id) {
this.updateCard(wallet, data)
} else {
this.createCard(wallet, data)
}
},
createCard: function (wallet, data) {
var self = this
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)
})
},
updateCardDialog: function (formId) {
var card = _.findWhere(this.cards, {id: formId})
console.log(card.id)
this.cardDialog.data = _.clone(card)
this.cardDialog.show = true
},
updateCard: function (wallet, data) {
var self = this
console.log(data)
LNbits.api
.request(
'PUT',
'/boltcards/api/v1/cards/' + data.id,
wallet.adminkey,
data
)
.then(function (response) {
self.cards = _.reject(self.cards, function (obj) {
return obj.id == data.id
})
self.cards.push(mapCards(response.data))
self.cardDialog.show = false
self.cardDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteCard: function (cardId) {
let self = this
let cards = _.findWhere(this.cards, {id: cardId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this card')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/boltcards/api/v1/cards/' + cardId,
_.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey
)
.then(function (response) {
self.cards = _.reject(self.cards, function (obj) {
return obj.id == cardId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCardsCSV: function () {
LNbits.utils.exportCSV(this.cardsTable.columns, this.cards)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getCards()
this.getWithdraws()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,18 @@
from fastapi import FastAPI, Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import boltcards_ext, boltcards_renderer
templates = Jinja2Templates(directory="templates")
@boltcards_ext.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()}
)

View File

@ -0,0 +1,161 @@
# 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)
from http import HTTPStatus
from fastapi.params import Depends, Query
from starlette.exceptions import HTTPException
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 .nxp424 import decryptSUN, getSunMAC
from .crud import (
get_all_cards,
get_cards,
get_card,
create_card,
update_card,
delete_card,
update_card_counter
)
from .models import CreateCardData
@boltcards_ext.get("/api/v1/cards")
async def api_cards(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return [card.dict() for card in await get_cards(wallet_ids)]
@boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED)
@boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK)
async def api_link_create_or_update(
req: Request,
data: CreateCardData,
card_id: str = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
'''
if data.uses > 250:
raise HTTPException(
detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST
)
if data.min_withdrawable < 1:
raise HTTPException(
detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST
)
if data.max_withdrawable < data.min_withdrawable:
raise HTTPException(
detail="`max_withdrawable` needs to be at least `min_withdrawable`.",
status_code=HTTPStatus.BAD_REQUEST,
)
'''
if card_id:
card = await get_card(card_id)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if card.wallet != wallet.wallet.id:
raise HTTPException(
detail="Not your card.", status_code=HTTPStatus.FORBIDDEN
)
card = await update_card(
card_id, **data.dict()
)
else:
card = await create_card(
wallet_id=wallet.wallet.id, data=data
)
return card.dict()
@boltcards_ext.delete("/api/v1/cards/{card_id}")
async def api_link_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
card = await get_card(card_id)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if card.wallet != wallet.wallet.id:
raise HTTPException(
detail="Not your card.", status_code=HTTPStatus.FORBIDDEN
)
await delete_card(card_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@boltcards_ext.get("/api/v1/scan/") # pay.btcslovnik.cz/boltcards/api/v1/scan/?uid=00000000000000&ctr=000000&c=0000000000000000
async def api_scan(
uid, ctr, c,
request: Request
):
card = await get_card(uid, id_is_uid=True)
if card == None:
return {"status": "ERROR", "reason": "Unknown card."}
if c != getSunMAC(bytes.fromhex(uid), bytes.fromhex(ctr)[::-1], bytes.fromhex(card.file_key)).hex().upper():
print(c)
print(getSunMAC(bytes.fromhex(uid), bytes.fromhex(ctr)[::-1], bytes.fromhex(card.file_key)).hex().upper())
return {"status": "ERROR", "reason": "CMAC does not check."}
ctr_int = int(ctr, 16)
if ctr_int <= card.counter:
return {"status": "ERROR", "reason": "This link is already used."}
await update_card_counter(ctr_int, card.id)
link = await get_withdraw_link(card.withdraw, 0)
return link.lnurl_response(request)
@boltcards_ext.get("/api/v1/scane/")
async def api_scane(
e, c,
request: Request
):
card = None
counter = b''
for cand in await get_all_cards():
if cand.meta_key:
card_uid, counter = decryptSUN(bytes.fromhex(e), bytes.fromhex(cand.meta_key))
if card_uid.hex().upper() == cand.uid:
card = cand
break
if card == None:
return {"status": "ERROR", "reason": "Unknown card."}
if c != getSunMAC(card_uid, counter, bytes.fromhex(card.file_key)).hex().upper():
print(c)
print(getSunMAC(card_uid, counter, bytes.fromhex(card.file_key)).hex().upper())
return {"status": "ERROR", "reason": "CMAC does not check."}
counter_int = int.from_bytes(counter, "little")
if counter_int <= card.counter:
return {"status": "ERROR", "reason": "This link is already used."}
await update_card_counter(counter_int, card.id)
link = await get_withdraw_link(card.withdraw, 0)
return link.lnurl_response(request)