Compare commits

..
45 Commits
Author SHA1 Message Date
calleandGitHub 925ed3c821 Merge pull request #1239 from lnbits/api/health_checl
feat: api health check endpoint
2022-12-25 18:51:40 +01:00
callebtc b6f0d7b769 feat: api health check endpoint 2022-12-25 18:49:51 +01:00
calleandGitHub 0df81e5aaf Merge pull request #1238 from lnbits/fix/cashu/wallet/duplicate_history
Cashu wallet: correctly update ui on token sending
2022-12-24 16:33:24 +01:00
callebtc 8e2cb187da fix: correctly update ui on token sending 2022-12-24 16:32:38 +01:00
calleandGitHub 13f0159c15 Merge pull request #1237 from lnbits/fix/cashu/timeout
cashu: refactor wallet
2022-12-24 14:02:13 +01:00
callebtc 5ecbbb1abe add warning 2022-12-24 13:58:39 +01:00
calleandGitHub 7b7d06cee3 Merge pull request #1232 from lnbits/fix/allowed_user
BUG: fix auth issue for superusers  when allowed users are set
2022-12-24 11:22:49 +01:00
callebtc b5147181af feat: add /keys/{keyset} endpoint 2022-12-24 04:03:04 +01:00
callebtc 1fd351b959 refactor wallet 2022-12-24 02:32:40 +01:00
ArcandGitHub e541f0224c Merge pull request #1236 from lnbits/lnurldeviceshorten
Swaps UUID for number, to reduce URL size
2022-12-23 22:56:40 +00:00
ben dd90c3a2eb format 2022-12-23 22:47:14 +00:00
ben cd6f4c909f swaps pos id for number 2022-12-23 22:38:19 +00:00
ben dce50105c9 no such column 2022-12-23 15:41:59 +00:00
ben 0ce987a20e await 2022-12-23 15:40:52 +00:00
ben 03846adbfb unsupported type 2022-12-23 15:40:05 +00:00
ben 886070755c hacky lnurlpos bump 2022-12-23 15:35:06 +00:00
dni ⚡ 1be460aec3 fix user not exist bug in frontend 2022-12-23 11:53:45 +01:00
calleandGitHub d0ed93aec8 Merge pull request #1234 from lnbits/fix/cashu/logging1
fix logger
2022-12-22 20:10:28 +01:00
callebtc 0d8ca58a67 fix logger 2022-12-22 20:10:01 +01:00
calleandGitHub e3ed2c4e6d Merge pull request #1233 from lnbits/fix/cashu/logging
cashu: add more logging
2022-12-22 19:58:47 +01:00
callebtc 090cf414e7 cashu: add more logging 2022-12-22 19:58:01 +01:00
dni ⚡ 1f75d5fd41 fix it up 2022-12-22 17:58:49 +01:00
dni ⚡ 7c96a12a3c fix issue with allowed_users and admin/superuser 2022-12-22 17:48:46 +01:00
calleandGitHub 5f920fd290 Merge pull request #1228 from AxelHamburch/Admin_UI_doc
Add "How to activat the Admin UI"
2022-12-22 11:08:03 +01:00
calleandGitHub 49a68df5e4 Apply suggestions from code review 2022-12-22 11:07:49 +01:00
AxelandGitHub 8d5f5e4afb Add "How to activat the Admin UI" 2022-12-21 21:33:10 +01:00
calleandGitHub 4e8014a177 Merge pull request #1227 from motorina0/withdraw_callback_extra
Withdraw webhook improvements
2022-12-21 14:52:07 +01:00
Vlad Stan 7b0f9f61fc fix: remove double dict 2022-12-21 15:47:35 +02:00
Vlad Stan f3c884111d refactor: use dict.update() 2022-12-21 15:47:22 +02:00
Vlad Stan ec01c74da1 fix: mypy 2022-12-21 15:10:12 +02:00
Vlad Stan 60cd40d5e5 feat: update_payment_extra with webhook response 2022-12-21 15:06:45 +02:00
Vlad Stan 0ab99bef41 refactor: use update_payment_extra 2022-12-21 15:06:45 +02:00
Vlad Stan 8fae90bb9d feat: add webhook_headers and webhook_body to withdraws 2022-12-21 15:06:45 +02:00
Vlad Stan dd4a9f10cf feat: add function to update the extra JSON values 2022-12-21 15:06:45 +02:00
Vlad Stan b68b8a0292 fix: do not loose error, log it 2022-12-21 15:06:45 +02:00
calleandGitHub 81df5a56e2 Merge pull request #1219 from islandbitcoin/patch-2
Remove duplicate LNBITS_ALLOWED_USERS
2022-12-20 16:25:56 +01:00
calleandGitHub 49e722d9da Merge pull request #1223 from lnbits/fix/mypy/usermanager
FIX: mypy issues with usermanager extension
2022-12-20 14:48:38 +01:00
dni ⚡ c2df13bf24 formatting 2022-12-20 14:23:02 +01:00
dni ⚡ 36ae851a8d nitpicking vlad :) 2022-12-20 13:44:14 +01:00
dni ⚡ 29869cfcda formatting 2022-12-20 13:18:37 +01:00
dni ⚡ 22cafe9ed0 remove uneeded if 2022-12-20 13:17:07 +01:00
dni ⚡ c90f7878d7 fix usermanager mypy 2022-12-20 13:14:49 +01:00
Vlad Stan c5fdd35078 feat: store mor info about the webhook call status 2022-12-20 13:32:03 +02:00
Vlad Stan 681883a8f9 fix: catch all exceptions for webhook 2022-12-20 13:32:03 +02:00
DreadandGitHub 97d1effe1c Remove duplicate LNBITS_ALLOWED_USERS 2022-12-19 18:55:05 -05:00
22 changed files with 732 additions and 396 deletions
-3
View File
@@ -14,9 +14,6 @@ LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available # Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available
LNBITS_ADMIN_UI=false LNBITS_ADMIN_UI=false
# Restricts access, User IDs seperated by comma
LNBITS_ALLOWED_USERS=""
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# Ad space description # Ad space description
+30
View File
@@ -40,3 +40,33 @@ Allowed Users
============= =============
enviroment variable: LNBITS_ALLOWED_USERS, comma-seperated list of user ids enviroment variable: LNBITS_ALLOWED_USERS, comma-seperated list of user ids
By defining this users, LNbits will no longer be useable by the public, only defined users and admins can then access the LNbits frontend. By defining this users, LNbits will no longer be useable by the public, only defined users and admins can then access the LNbits frontend.
How to activate
=============
```
$ sudo systemctl stop lnbits.service
$ cd ~/lnbits-legend
$ sudo nano .env
```
-> set: `LNBITS_ADMIN_UI=true`
Now start LNbits once in the terminal window
```
$ poetry run lnbits
```
It will now show you the Super User Account:
`SUCCESS | ✔️ Access super user account at: https://127.0.0.1:5000/wallet?usr=5711d7..`
The `/wallet?usr=..` is your super user account. You just have to append it to your normal LNbits web domain.
After that you will find the __`Admin` / `Manage Server`__ between `Wallets` and `Extensions`
Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
Do not forget
```
sudo systemctl start lnbits.service
```
A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
+28
View File
@@ -451,6 +451,34 @@ async def update_payment_details(
return return
async def update_payment_extra(
payment_hash: str,
extra: dict,
conn: Optional[Connection] = None,
) -> None:
"""
Only update the `extra` field for the payment.
Old values in the `extra` JSON object will be kept unless the new `extra` overwrites them.
"""
row = await (conn or db).fetchone(
"SELECT hash, extra from apipayments WHERE hash = ?",
(payment_hash,),
)
if not row:
return
db_extra = json.loads(row["extra"] if row["extra"] else "{}")
db_extra.update(extra)
await (conn or db).execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(db_extra), payment_hash),
)
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None: async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute( await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,) "DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
+7 -1
View File
@@ -71,6 +71,11 @@ from ..services import (
from ..tasks import api_invoice_listeners from ..tasks import api_invoice_listeners
@core_app.get("/api/v1/health", status_code=HTTPStatus.OK)
async def health():
return
@core_app.get("/api/v1/wallet") @core_app.get("/api/v1/wallet")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.wallet_type == 0: if wallet.wallet_type == 0:
@@ -214,7 +219,8 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
lnurl_response = resp["reason"] lnurl_response = resp["reason"]
else: else:
lnurl_response = True lnurl_response = True
except (httpx.ConnectError, httpx.RequestError): except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
lnurl_response = False lnurl_response = False
return { return {
+4 -1
View File
@@ -38,7 +38,7 @@ async def favicon():
@core_html_routes.get("/", response_class=HTMLResponse) @core_html_routes.get("/", response_class=HTMLResponse)
async def home(request: Request, lightning: str = None): async def home(request: Request, lightning: str = ""):
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
"core/index.html", {"request": request, "lnurl": lightning} "core/index.html", {"request": request, "lnurl": lightning}
) )
@@ -124,12 +124,15 @@ async def wallet(
if ( if (
len(settings.lnbits_allowed_users) > 0 len(settings.lnbits_allowed_users) > 0
and user_id not in settings.lnbits_allowed_users and user_id not in settings.lnbits_allowed_users
and user_id not in settings.lnbits_admin_users
and user_id != settings.super_user
): ):
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User not authorized."} "error.html", {"request": request, "err": "User not authorized."}
) )
if user_id == settings.super_user or user_id in settings.lnbits_admin_users: if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
user.admin = True user.admin = True
if not wallet_id: if not wallet_id:
if user.wallets and not wallet_name: # type: ignore if user.wallets and not wallet_name: # type: ignore
wallet = user.wallets[0] # type: ignore wallet = user.wallets[0] # type: ignore
+1 -1
View File
@@ -236,8 +236,8 @@ async def check_user_exists(usr: UUID4) -> User:
if ( if (
len(settings.lnbits_allowed_users) > 0 len(settings.lnbits_allowed_users) > 0
and g().user.id not in settings.lnbits_allowed_users and g().user.id not in settings.lnbits_allowed_users
and g().user.id != settings.super_user
and g().user.id not in settings.lnbits_admin_users and g().user.id not in settings.lnbits_admin_users
and g().user.id != settings.super_user
): ):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
@@ -159,7 +159,7 @@ page_container %}
size="lg" size="lg"
color="secondary" color="secondary"
class="q-mr-md cursor-pointer" class="q-mr-md cursor-pointer"
@click="recheckInvoice(props.row.hash)" @click="checkInvoice(props.row.hash)"
> >
Check Check
</q-badge> </q-badge>
@@ -1528,57 +1528,17 @@ page_container %}
return proofs.reduce((s, t) => (s += t.amount), 0) return proofs.reduce((s, t) => (s += t.amount), 0)
}, },
deleteProofs: function (proofs) {
// delete proofs from this.proofs
const usedSecrets = proofs.map(p => p.secret)
this.proofs = this.proofs.filter(p => !usedSecrets.includes(p.secret))
this.storeProofs()
return this.proofs
},
//////////// API /////////// //////////// API ///////////
clearAllWorkers: function () {
if (this.invoiceCheckListener) {
clearInterval(this.invoiceCheckListener)
}
if (this.tokensCheckSpendableListener) {
clearInterval(this.tokensCheckSpendableListener)
}
},
invoiceCheckWorker: async function () {
let nInterval = 0
this.clearAllWorkers()
this.invoiceCheckListener = setInterval(async () => {
try {
nInterval += 1
// exit loop after 2m // MINT
if (nInterval > 40) {
console.log('### stopping invoice check worker')
this.clearAllWorkers()
}
console.log('### invoiceCheckWorker setInterval', nInterval)
console.log(this.invoiceData)
// this will throw an error if the invoice is pending
await this.recheckInvoice(this.invoiceData.hash, false)
// only without error (invoice paid) will we reach here
console.log('### stopping invoice check worker')
this.clearAllWorkers()
this.invoiceData.bolt11 = ''
this.showInvoiceDetails = false
if (window.navigator.vibrate) navigator.vibrate(200)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Payment received',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
} catch (error) {
console.log('not paid yet')
}
}, 3000)
},
requestMintButton: async function () { requestMintButton: async function () {
await this.requestMint() await this.requestMint()
@@ -1586,8 +1546,12 @@ page_container %}
await this.invoiceCheckWorker() await this.invoiceCheckWorker()
}, },
// /mint
requestMint: async function () { requestMint: async function () {
// gets an invoice from the mint to get new tokens /*
gets an invoice from the mint to get new tokens
*/
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
@@ -1611,7 +1575,14 @@ page_container %}
throw error throw error
} }
}, },
// /mint
mintApi: async function (amounts, payment_hash, verbose = true) { mintApi: async function (amounts, payment_hash, verbose = true) {
/*
asks the mint to check whether the invoice with payment_hash has been paid
and requests signing of the attached outputs (blindedMessages)
*/
console.log('### promises', payment_hash) console.log('### promises', payment_hash)
try { try {
let secrets = await this.generateSecrets(amounts) let secrets = await this.generateSecrets(amounts)
@@ -1647,7 +1618,19 @@ page_container %}
} }
this.proofs = this.proofs.concat(proofs) this.proofs = this.proofs.concat(proofs)
this.storeProofs() this.storeProofs()
// update UI
await this.setInvoicePaid(payment_hash) await this.setInvoicePaid(payment_hash)
tokensBase64 = btoa(JSON.stringify(proofs))
this.historyTokens.push({
status: 'paid',
amount: amount,
date: currentDateStr(),
token: tokensBase64
})
this.storehistoryTokens()
return proofs return proofs
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -1657,62 +1640,20 @@ page_container %}
throw error throw error
} }
}, },
splitToSend: async function (proofs, amount, invlalidate = false) {
// splits proofs so the user can keep firstProofs, send scndProofs
try {
const spendableProofs = proofs.filter(p => !p.reserved)
if (this.sumProofs(spendableProofs) < amount) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Balance too low',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
throw Error('balance too low.')
}
let {fristProofs, scndProofs} = await this.split(
spendableProofs,
amount
)
// set scndProofs in this.proofs as reserved // SPLIT
const usedSecrets = proofs.map(p => p.secret)
for (let i = 0; i < this.proofs.length; i++) {
if (usedSecrets.includes(this.proofs[i].secret)) {
this.proofs[i].reserved = true
}
}
if (invlalidate) {
// delete tokens from db
this.proofs = fristProofs
// add new fristProofs, scndProofs to this.proofs
this.storeProofs()
}
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
split: async function (proofs, amount) { split: async function (proofs, amount) {
/*
supplies proofs and requests a split from the mint of these
proofs at a specific amount
*/
try { try {
if (proofs.length == 0) { if (proofs.length == 0) {
throw new Error('no proofs provided.') throw new Error('no proofs provided.')
} }
let {fristProofs, scndProofs} = await this.splitApi(proofs, amount) let {fristProofs, scndProofs} = await this.splitApi(proofs, amount)
// delete proofs from this.proofs this.deleteProofs(proofs)
const usedSecrets = proofs.map(p => p.secret)
this.proofs = this.proofs.filter(p => !usedSecrets.includes(p.secret))
// add new fristProofs, scndProofs to this.proofs // add new fristProofs, scndProofs to this.proofs
this.proofs = this.proofs.concat(fristProofs).concat(scndProofs) this.proofs = this.proofs.concat(fristProofs).concat(scndProofs)
this.storeProofs() this.storeProofs()
@@ -1723,6 +1664,9 @@ page_container %}
throw error throw error
} }
}, },
// /split
splitApi: async function (proofs, amount) { splitApi: async function (proofs, amount) {
try { try {
const total = this.sumProofs(proofs) const total = this.sumProofs(proofs)
@@ -1782,7 +1726,62 @@ page_container %}
} }
}, },
splitToSend: async function (proofs, amount, invlalidate = false) {
/*
splits proofs so the user can keep firstProofs, send scndProofs.
then sets scndProofs as reserved.
if invalidate, scndProofs (the one to send) are invalidated
*/
try {
const spendableProofs = proofs.filter(p => !p.reserved)
if (this.sumProofs(spendableProofs) < amount) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Balance too low',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
throw Error('balance too low.')
}
// call /split
let {fristProofs, scndProofs} = await this.split(
spendableProofs,
amount
)
// set scndProofs in this.proofs as reserved
const usedSecrets = proofs.map(p => p.secret)
for (let i = 0; i < this.proofs.length; i++) {
if (usedSecrets.includes(this.proofs[i].secret)) {
this.proofs[i].reserved = true
}
}
if (invlalidate) {
// delete scndProofs from db
this.deleteProofs(scndProofs)
}
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
redeem: async function () { redeem: async function () {
/*
uses split to receive new tokens.
*/
this.showReceiveTokens = false this.showReceiveTokens = false
console.log('### receive tokens', this.receiveData.tokensBase64) console.log('### receive tokens', this.receiveData.tokensBase64)
try { try {
@@ -1793,6 +1792,9 @@ page_container %}
const proofs = JSON.parse(tokenJson) const proofs = JSON.parse(tokenJson)
const amount = proofs.reduce((s, t) => (s += t.amount), 0) const amount = proofs.reduce((s, t) => (s += t.amount), 0)
let {fristProofs, scndProofs} = await this.split(proofs, amount) let {fristProofs, scndProofs} = await this.split(proofs, amount)
// update UI
// HACK: we need to do this so the balance updates // HACK: we need to do this so the balance updates
this.proofs = this.proofs.concat([]) this.proofs = this.proofs.concat([])
@@ -1827,13 +1829,18 @@ page_container %}
}, },
sendTokens: async function () { sendTokens: async function () {
/*
calls splitToSend, displays token and kicks off the spendableWorker
*/
try { try {
// keep firstProofs, send scndProofs // keep firstProofs, send scndProofs and delete them (invalidate=true)
let {fristProofs, scndProofs} = await this.splitToSend( let {fristProofs, scndProofs} = await this.splitToSend(
this.proofs, this.proofs,
this.sendData.amount, this.sendData.amount,
true true
) )
// update UI
this.sendData.tokens = scndProofs this.sendData.tokens = scndProofs
console.log('### this.sendData.tokens', this.sendData.tokens) console.log('### this.sendData.tokens', this.sendData.tokens)
this.sendData.tokensBase64 = btoa( this.sendData.tokensBase64 = btoa(
@@ -1846,33 +1853,19 @@ page_container %}
date: currentDateStr(), date: currentDateStr(),
token: this.sendData.tokensBase64 token: this.sendData.tokensBase64
}) })
// store "pending" outgoing tokens in history table
this.storehistoryTokens() this.storehistoryTokens()
this.checkTokenSpendableWorker() this.checkTokenSpendableWorker()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
throw error throw error
} }
}, },
checkFees: async function (payment_request) {
const payload = { // /melt
pr: payment_request
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/checkfees`,
'',
payload
)
console.log('#### checkFees', payment_request, data.fee)
return data.fee
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
melt: async function () { melt: async function () {
// todo: get fees from server and add to inputs // todo: get fees from server and add to inputs
this.payInvoiceData.blocking = true this.payInvoiceData.blocking = true
@@ -1924,8 +1917,20 @@ page_container %}
] ]
}) })
// delete spent tokens from db // delete spent tokens from db
this.proofs = fristProofs this.deleteProofs(scndProofs)
this.storeProofs()
// update UI
tokensBase64 = btoa(JSON.stringify(scndProofs))
this.historyTokens.push({
status: 'paid',
amount: -amount,
date: currentDateStr(),
token: tokensBase64
})
this.storehistoryTokens()
console.log({ console.log({
amount: -amount, amount: -amount,
bolt11: this.payInvoiceData.data.request, bolt11: this.payInvoiceData.data.request,
@@ -1953,13 +1958,95 @@ page_container %}
throw error throw error
} }
}, },
// /check
checkProofsSpendable: async function (proofs, update_history = false) {
/*
checks with the mint whether an array of proofs is still
spendable or already invalidated
*/
const payload = {
proofs: proofs.flat()
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/check`,
'',
payload
)
// delete proofs from database if it is spent
let spentProofs = proofs.filter((p, pidx) => !data[pidx])
if (spentProofs.length) {
this.deleteProofs(spentProofs)
// update UI
if (update_history) {
tokensBase64 = btoa(JSON.stringify(spentProofs))
this.historyTokens.push({
status: 'paid',
amount: -this.sumProofs(spentProofs),
date: currentDateStr(),
token: tokensBase64
})
this.storehistoryTokens()
}
}
return data
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// /checkfees
checkFees: async function (payment_request) {
const payload = {
pr: payment_request
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/checkfees`,
'',
payload
)
console.log('#### checkFees', payment_request, data.fee)
return data.fee
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// /keys
fetchMintKeys: async function () {
const {data} = await LNbits.api.request(
'GET',
`/cashu/api/v1/${this.mintId}/keys`
)
this.keys = data
localStorage.setItem(
this.mintKey(this.mintId, 'keys'),
JSON.stringify(data)
)
},
setInvoicePaid: async function (payment_hash) { setInvoicePaid: async function (payment_hash) {
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash) const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
invoice.status = 'paid' invoice.status = 'paid'
this.storeinvoicesCashu() this.storeinvoicesCashu()
}, },
recheckInvoice: async function (payment_hash, verbose = true) { checkInvoice: async function (payment_hash, verbose = true) {
console.log('### recheckInvoice.hash', payment_hash) console.log('### checkInvoice.hash', payment_hash)
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash) const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
try { try {
proofs = await this.mint(invoice.amount, invoice.hash, verbose) proofs = await this.mint(invoice.amount, invoice.hash, verbose)
@@ -1969,15 +2056,15 @@ page_container %}
throw error throw error
} }
}, },
recheckPendingInvoices: async function () { checkPendingInvoices: async function () {
for (const invoice of this.invoicesCashu) { for (const invoice of this.invoicesCashu) {
if (invoice.status === 'pending' && invoice.sat > 0) { if (invoice.status === 'pending' && invoice.amount > 0) {
this.recheckInvoice(invoice.hash, false) this.checkInvoice(invoice.hash, false)
} }
} }
}, },
recheckPendingTokens: async function () { checkPendingTokens: async function () {
for (const token of this.historyTokens) { for (const token of this.historyTokens) {
if (token.status === 'pending' && token.amount < 0) { if (token.status === 'pending' && token.amount < 0) {
this.checkTokenSpendable(token.token, false) this.checkTokenSpendable(token.token, false)
@@ -1990,6 +2077,113 @@ page_container %}
this.storehistoryTokens() this.storehistoryTokens()
}, },
checkTokenSpendable: async function (token, verbose = true) {
/*
checks whether a base64-encoded token (from the history table) has been spent already.
if it is spent, the appropraite entry in the history table is set to paid.
*/
const tokenJson = atob(token)
const proofs = JSON.parse(tokenJson)
let data = await this.checkProofsSpendable(proofs)
// iterate through response of form {0: true, 1: false, ...}
let paid = false
for (const [key, spendable] of Object.entries(data)) {
if (!spendable) {
this.setTokenPaid(token)
paid = true
}
}
if (paid) {
console.log('### token paid')
if (window.navigator.vibrate) navigator.vibrate(200)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Token paid',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
} else {
console.log('### token not paid yet')
if (verbose) {
this.$q.notify({
timeout: 5000,
color: 'grey',
message: 'Token still pending',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
}
this.sendData.tokens = token
}
return paid
},
////////////// WORKERS //////////////
clearAllWorkers: function () {
if (this.invoiceCheckListener) {
clearInterval(this.invoiceCheckListener)
}
if (this.tokensCheckSpendableListener) {
clearInterval(this.tokensCheckSpendableListener)
}
},
invoiceCheckWorker: async function () {
let nInterval = 0
this.clearAllWorkers()
this.invoiceCheckListener = setInterval(async () => {
try {
nInterval += 1
// exit loop after 2m
if (nInterval > 40) {
console.log('### stopping invoice check worker')
this.clearAllWorkers()
}
console.log('### invoiceCheckWorker setInterval', nInterval)
console.log(this.invoiceData)
// this will throw an error if the invoice is pending
await this.checkInvoice(this.invoiceData.hash, false)
// only without error (invoice paid) will we reach here
console.log('### stopping invoice check worker')
this.clearAllWorkers()
this.invoiceData.bolt11 = ''
this.showInvoiceDetails = false
if (window.navigator.vibrate) navigator.vibrate(200)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Payment received',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
} catch (error) {
console.log('not paid yet')
}
}, 3000)
},
checkTokenSpendableWorker: async function () { checkTokenSpendableWorker: async function () {
let nInterval = 0 let nInterval = 0
this.clearAllWorkers() this.clearAllWorkers()
@@ -2021,83 +2215,6 @@ page_container %}
}, 3000) }, 3000)
}, },
checkTokenSpendable: async function (token, verbose = true) {
const tokenJson = atob(token)
const proofs = JSON.parse(tokenJson)
const payload = {
proofs: proofs.flat()
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/check`,
'',
payload
)
// iterate through response of form {0: true, 1: false, ...}
let paid = false
for (const [key, spendable] of Object.entries(data)) {
if (!spendable) {
this.setTokenPaid(token)
paid = true
}
}
if (paid) {
console.log('### token paid')
if (window.navigator.vibrate) navigator.vibrate(200)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Token paid',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
} else {
console.log('### token not paid yet')
if (verbose) {
this.$q.notify({
timeout: 5000,
color: 'grey',
message: 'Token still pending',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
}
this.sendData.tokens = token
}
return paid
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
fetchMintKeys: async function () {
const {data} = await LNbits.api.request(
'GET',
`/cashu/api/v1/${this.mintId}/keys`
)
this.keys = data
localStorage.setItem(
this.mintKey(this.mintId, 'keys'),
JSON.stringify(data)
)
},
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -2116,62 +2233,62 @@ page_container %}
} }
}, },
checkInvoice: function () { // checkInvoice: function () {
console.log('#### checkInvoice') // console.log('#### checkInvoice')
try { // try {
const invoice = decode(this.payInvoiceData.data.request) // const invoice = decode(this.payInvoiceData.data.request)
const cleanInvoice = { // const cleanInvoice = {
msat: invoice.human_readable_part.amount, // msat: invoice.human_readable_part.amount,
sat: invoice.human_readable_part.amount / 1000, // sat: invoice.human_readable_part.amount / 1000,
fsat: LNbits.utils.formatSat( // fsat: LNbits.utils.formatSat(
invoice.human_readable_part.amount / 1000 // invoice.human_readable_part.amount / 1000
) // )
} // }
_.each(invoice.data.tags, tag => { // _.each(invoice.data.tags, tag => {
if (_.isObject(tag) && _.has(tag, 'description')) { // if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description === 'payment_hash') { // if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value // cleanInvoice.hash = tag.value
} else if (tag.description === 'description') { // } else if (tag.description === 'description') {
cleanInvoice.description = tag.value // cleanInvoice.description = tag.value
} else if (tag.description === 'expiry') { // } else if (tag.description === 'expiry') {
var expireDate = new Date( // var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000 // (invoice.data.time_stamp + tag.value) * 1000
) // )
cleanInvoice.expireDate = Quasar.utils.date.formatDate( // cleanInvoice.expireDate = Quasar.utils.date.formatDate(
expireDate, // expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ' // 'YYYY-MM-DDTHH:mm:ss.SSSZ'
) // )
cleanInvoice.expired = false // TODO // cleanInvoice.expired = false // TODO
} // }
} // }
this.payInvoiceData.invoice = cleanInvoice // this.payInvoiceData.invoice = cleanInvoice
}) // })
console.log( // console.log(
'#### this.payInvoiceData.invoice', // '#### this.payInvoiceData.invoice',
this.payInvoiceData.invoice // this.payInvoiceData.invoice
) // )
} catch (error) { // } catch (error) {
this.$q.notify({ // this.$q.notify({
timeout: 5000, // timeout: 5000,
type: 'warning', // type: 'warning',
message: 'Could not decode invoice', // message: 'Could not decode invoice',
caption: error + '', // caption: error + '',
position: 'top', // position: 'top',
actions: [ // actions: [
{ // {
icon: 'close', // icon: 'close',
color: 'white', // color: 'white',
handler: () => {} // handler: () => {}
} // }
] // ]
}) // })
throw error // throw error
} // }
}, // },
////////////// STORAGE ///////////// ////////////// STORAGE /////////////
@@ -2335,8 +2452,9 @@ page_container %}
console.log('#### this.mintId', this.mintId) console.log('#### this.mintId', this.mintId)
console.log('#### this.mintName', this.mintName) console.log('#### this.mintName', this.mintName)
this.recheckPendingInvoices() this.checkProofsSpendable(this.proofs, true)
this.recheckPendingTokens() this.checkPendingInvoices()
this.checkPendingTokens()
} }
}) })
</script> </script>
+97 -43
View File
@@ -46,9 +46,16 @@ from .models import Cashu
# --------- extension imports # --------- extension imports
# WARNING: Do not set this to False in production! This will create
# tokens for free otherwise. This is for testing purposes only!
LIGHTNING = True LIGHTNING = True
if not LIGHTNING:
logger.warning(
"Cashu: LIGHTNING is set False! That means that I will create ecash for free!"
)
######################################## ########################################
############### LNBITS MINTS ########### ############### LNBITS MINTS ###########
######################################## ########################################
@@ -130,6 +137,28 @@ async def keys(cashu_id: str = Query(None)) -> dict[int, str]:
return ledger.get_keyset(keyset_id=cashu.keyset_id) return ledger.get_keyset(keyset_id=cashu.keyset_id)
@cashu_ext.get("/api/v1/{cashu_id}/keys/{idBase64Urlsafe}")
async def keyset_keys(
cashu_id: str = Query(None), idBase64Urlsafe: str = Query(None)
) -> dict[int, str]:
"""
Get the public keys of the mint of a specificy keyset id.
The id is encoded in base64_urlsafe and needs to be converted back to
normal base64 before it can be processed.
"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
id = idBase64Urlsafe.replace("-", "+").replace("_", "/")
keyset = ledger.get_keyset(keyset_id=id)
return keyset
@cashu_ext.get("/api/v1/{cashu_id}/keysets", status_code=HTTPStatus.OK) @cashu_ext.get("/api/v1/{cashu_id}/keysets", status_code=HTTPStatus.OK)
async def keysets(cashu_id: str = Query(None)) -> dict[str, list[str]]: async def keysets(cashu_id: str = Query(None)) -> dict[str, list[str]]:
"""Get the public keys of the mint""" """Get the public keys of the mint"""
@@ -182,7 +211,7 @@ async def request_mint(cashu_id: str = Query(None), amount: int = 0) -> GetMintR
@cashu_ext.post("/api/v1/{cashu_id}/mint") @cashu_ext.post("/api/v1/{cashu_id}/mint")
async def mint_coins( async def mint(
data: MintRequest, data: MintRequest,
cashu_id: str = Query(None), cashu_id: str = Query(None),
payment_hash: str = Query(None), payment_hash: str = Query(None),
@@ -197,6 +226,8 @@ async def mint_coins(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
) )
keyset = ledger.keysets.keysets[cashu.keyset_id]
if LIGHTNING: if LIGHTNING:
invoice: Invoice = await ledger.crud.get_lightning_invoice( invoice: Invoice = await ledger.crud.get_lightning_invoice(
db=ledger.db, hash=payment_hash db=ledger.db, hash=payment_hash
@@ -206,42 +237,55 @@ async def mint_coins(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
detail="Mint does not know this invoice.", detail="Mint does not know this invoice.",
) )
if invoice.issued == True: if invoice.issued:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED, status_code=HTTPStatus.PAYMENT_REQUIRED,
detail="Tokens already issued for this invoice.", detail="Tokens already issued for this invoice.",
) )
total_requested = sum([bm.amount for bm in data.blinded_messages]) # set this invoice as issued
if total_requested > invoice.amount:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
)
status: PaymentStatus = await check_transaction_status(cashu.wallet, payment_hash)
if LIGHTNING and status.paid != True:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
)
try:
keyset = ledger.keysets.keysets[cashu.keyset_id]
promises = await ledger._generate_promises(
B_s=data.blinded_messages, keyset=keyset
)
assert len(promises), HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="No promises returned."
)
await ledger.crud.update_lightning_invoice( await ledger.crud.update_lightning_invoice(
db=ledger.db, hash=payment_hash, issued=True db=ledger.db, hash=payment_hash, issued=True
) )
status: PaymentStatus = await check_transaction_status(
cashu.wallet, payment_hash
)
try:
total_requested = sum([bm.amount for bm in data.blinded_messages])
if total_requested > invoice.amount:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
)
if not status.paid:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
)
promises = await ledger._generate_promises(
B_s=data.blinded_messages, keyset=keyset
)
return promises
except (Exception, HTTPException) as e:
logger.debug(f"Cashu: /melt {str(e) or getattr(e, 'detail')}")
# unset issued flag because something went wrong
await ledger.crud.update_lightning_invoice(
db=ledger.db, hash=payment_hash, issued=False
)
raise HTTPException(
status_code=getattr(e, "status_code")
or HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(e) or getattr(e, "detail"),
)
else:
# only used for testing when LIGHTNING=false
promises = await ledger._generate_promises(
B_s=data.blinded_messages, keyset=keyset
)
return promises return promises
except Exception as e:
logger.error(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
@cashu_ext.post("/api/v1/{cashu_id}/melt") @cashu_ext.post("/api/v1/{cashu_id}/melt")
@@ -285,28 +329,38 @@ async def melt_coins(
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)." f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
) )
logger.debug(f"Cashu: Initiating payment of {total_provided} sats") logger.debug(f"Cashu: Initiating payment of {total_provided} sats")
await pay_invoice( try:
wallet_id=cashu.wallet, await pay_invoice(
payment_request=invoice, wallet_id=cashu.wallet,
description=f"Pay cashu invoice", payment_request=invoice,
extra={"tag": "cashu", "cashu_name": cashu.name}, description=f"Pay cashu invoice",
) extra={"tag": "cashu", "cashu_name": cashu.name},
)
logger.debug( except Exception as e:
f"Cashu: Wallet {cashu.wallet} checking PaymentStatus of {invoice_obj.payment_hash}" logger.debug(f"Cashu error paying invoice {invoice_obj.payment_hash}: {e}")
) raise e
status: PaymentStatus = await check_transaction_status( finally:
cashu.wallet, invoice_obj.payment_hash logger.debug(
) f"Cashu: Wallet {cashu.wallet} checking PaymentStatus of {invoice_obj.payment_hash}"
if status.paid == True: )
logger.debug("Cashu: Payment successful, invalidating proofs") status: PaymentStatus = await check_transaction_status(
await ledger._invalidate_proofs(proofs) cashu.wallet, invoice_obj.payment_hash
)
if status.paid == True:
logger.debug(
f"Cashu: Payment successful, invalidating proofs for {invoice_obj.payment_hash}"
)
await ledger._invalidate_proofs(proofs)
else:
logger.debug(f"Cashu: Payment failed for {invoice_obj.payment_hash}")
except Exception as e: except Exception as e:
logger.debug(f"Cashu: Exception for {invoice_obj.payment_hash}: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=f"Cashu: {str(e)}", detail=f"Cashu: {str(e)}",
) )
finally: finally:
logger.debug(f"Cashu: Unset pending for {invoice_obj.payment_hash}")
# delete proofs from pending list # delete proofs from pending list
await ledger._unset_proofs_pending(proofs) await ledger._unset_proofs_pending(proofs)
+15 -1
View File
@@ -11,7 +11,10 @@ from .models import createLnurldevice, lnurldevicepayment, lnurldevices
async def create_lnurldevice( async def create_lnurldevice(
data: createLnurldevice, data: createLnurldevice,
) -> lnurldevices: ) -> lnurldevices:
lnurldevice_id = urlsafe_short_hash() if data.device == "pos" or data.device == "atm":
lnurldevice_id = str(await get_lnurldeviceposcount())
else:
lnurldevice_id = urlsafe_short_hash()
lnurldevice_key = urlsafe_short_hash() lnurldevice_key = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
@@ -79,6 +82,17 @@ async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldev
return lnurldevices(**row) if row else None return lnurldevices(**row) if row else None
async def get_lnurldeviceposcount() -> int:
row = await db.fetchall(
"SELECT * FROM lnurldevice.lnurldevices WHERE device = ? OR device = ?",
(
"pos",
"atm",
),
)
return len(row) + 1
async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices: async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,) "SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)
+2 -2
View File
@@ -17,8 +17,8 @@ class createLnurldevice(BaseModel):
wallet: str wallet: str
currency: str currency: str
device: str device: str
profit: float profit: float = 0
amount: int amount: Optional[int] = 0
pin: int = 0 pin: int = 0
profit1: float = 0 profit1: float = 0
amount1: int = 0 amount1: int = 0
+17 -13
View File
@@ -2,8 +2,10 @@ import asyncio
import json import json
import httpx import httpx
from loguru import logger
from lnbits.core import db as core_db from lnbits.core import db as core_db
from lnbits.core.crud import update_payment_extra
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
@@ -48,19 +50,21 @@ async def on_invoice_paid(payment: Payment) -> None:
if pay_link.webhook_headers: if pay_link.webhook_headers:
kwargs["headers"] = json.loads(pay_link.webhook_headers) kwargs["headers"] = json.loads(pay_link.webhook_headers)
r = await client.post(pay_link.webhook_url, **kwargs) r: httpx.Response = await client.post(pay_link.webhook_url, **kwargs)
await mark_webhook_sent(payment, r.status_code) await mark_webhook_sent(
except (httpx.ConnectError, httpx.RequestError): payment, r.status_code, r.is_success, r.reason_phrase, r.text
await mark_webhook_sent(payment, -1) )
except Exception as ex:
logger.error(ex)
await mark_webhook_sent(payment, -1, False, "Unexpected Error", str(ex))
async def mark_webhook_sent(payment: Payment, status: int) -> None: async def mark_webhook_sent(
payment.extra["wh_status"] = status payment: Payment, status: int, is_success: bool, reason_phrase="", text=""
) -> None:
payment.extra["wh_status"] = status # keep for backwards compability
payment.extra["wh_success"] = is_success
payment.extra["wh_message"] = reason_phrase
payment.extra["wh_response"] = text
await core_db.execute( await update_payment_extra(payment.payment_hash, payment.extra)
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)
+14 -19
View File
@@ -10,12 +10,10 @@ from lnbits.core.crud import (
from lnbits.core.models import Payment from lnbits.core.models import Payment
from . import db from . import db
from .models import CreateUserData, Users, Wallets from .models import CreateUserData, User, Wallet
### Users
async def create_usermanager_user(data: CreateUserData) -> Users: async def create_usermanager_user(data: CreateUserData) -> User:
account = await create_account() account = await create_account()
user = await get_user(account.id) user = await get_user(account.id)
assert user, "Newly created user couldn't be retrieved" assert user, "Newly created user couldn't be retrieved"
@@ -50,17 +48,17 @@ async def create_usermanager_user(data: CreateUserData) -> Users:
return user_created return user_created
async def get_usermanager_user(user_id: str) -> Optional[Users]: async def get_usermanager_user(user_id: str) -> Optional[User]:
row = await db.fetchone("SELECT * FROM usermanager.users WHERE id = ?", (user_id,)) row = await db.fetchone("SELECT * FROM usermanager.users WHERE id = ?", (user_id,))
return Users(**row) if row else None return User(**row) if row else None
async def get_usermanager_users(user_id: str) -> List[Users]: async def get_usermanager_users(user_id: str) -> List[User]:
rows = await db.fetchall( rows = await db.fetchall(
"SELECT * FROM usermanager.users WHERE admin = ?", (user_id,) "SELECT * FROM usermanager.users WHERE admin = ?", (user_id,)
) )
return [Users(**row) for row in rows] return [User(**row) for row in rows]
async def delete_usermanager_user(user_id: str, delete_core: bool = True) -> None: async def delete_usermanager_user(user_id: str, delete_core: bool = True) -> None:
@@ -73,12 +71,9 @@ async def delete_usermanager_user(user_id: str, delete_core: bool = True) -> Non
await db.execute("""DELETE FROM usermanager.wallets WHERE "user" = ?""", (user_id,)) await db.execute("""DELETE FROM usermanager.wallets WHERE "user" = ?""", (user_id,))
### Wallets
async def create_usermanager_wallet( async def create_usermanager_wallet(
user_id: str, wallet_name: str, admin_id: str user_id: str, wallet_name: str, admin_id: str
) -> Wallets: ) -> Wallet:
wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name) wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name)
await db.execute( await db.execute(
""" """
@@ -92,28 +87,28 @@ async def create_usermanager_wallet(
return wallet_created return wallet_created
async def get_usermanager_wallet(wallet_id: str) -> Optional[Wallets]: async def get_usermanager_wallet(wallet_id: str) -> Optional[Wallet]:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM usermanager.wallets WHERE id = ?", (wallet_id,) "SELECT * FROM usermanager.wallets WHERE id = ?", (wallet_id,)
) )
return Wallets(**row) if row else None return Wallet(**row) if row else None
async def get_usermanager_wallets(admin_id: str) -> Optional[Wallets]: async def get_usermanager_wallets(admin_id: str) -> List[Wallet]:
rows = await db.fetchall( rows = await db.fetchall(
"SELECT * FROM usermanager.wallets WHERE admin = ?", (admin_id,) "SELECT * FROM usermanager.wallets WHERE admin = ?", (admin_id,)
) )
return [Wallets(**row) for row in rows] return [Wallet(**row) for row in rows]
async def get_usermanager_users_wallets(user_id: str) -> Optional[Wallets]: async def get_usermanager_users_wallets(user_id: str) -> List[Wallet]:
rows = await db.fetchall( rows = await db.fetchall(
"""SELECT * FROM usermanager.wallets WHERE "user" = ?""", (user_id,) """SELECT * FROM usermanager.wallets WHERE "user" = ?""", (user_id,)
) )
return [Wallets(**row) for row in rows] return [Wallet(**row) for row in rows]
async def get_usermanager_wallet_transactions(wallet_id: str) -> Optional[Payment]: async def get_usermanager_wallet_transactions(wallet_id: str) -> List[Payment]:
return await get_payments( return await get_payments(
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
) )
+3 -3
View File
@@ -19,7 +19,7 @@ class CreateUserWallet(BaseModel):
admin_id: str = Query(...) admin_id: str = Query(...)
class Users(BaseModel): class User(BaseModel):
id: str id: str
name: str name: str
admin: str admin: str
@@ -27,7 +27,7 @@ class Users(BaseModel):
password: Optional[str] = None password: Optional[str] = None
class Wallets(BaseModel): class Wallet(BaseModel):
id: str id: str
admin: str admin: str
name: str name: str
@@ -36,5 +36,5 @@ class Wallets(BaseModel):
inkey: str inkey: str
@classmethod @classmethod
def from_row(cls, row: Row) -> "Wallets": def from_row(cls, row: Row) -> "Wallet":
return cls(**dict(row)) return cls(**dict(row))
+3 -1
View File
@@ -9,7 +9,9 @@ from . import usermanager_ext, usermanager_renderer
@usermanager_ext.get("/", response_class=HTMLResponse) @usermanager_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(
request: Request, user: User = Depends(check_user_exists) # type: ignore
):
return usermanager_renderer().TemplateResponse( return usermanager_renderer().TemplateResponse(
"usermanager/index.html", {"request": request, "user": user.dict()} "usermanager/index.html", {"request": request, "user": user.dict()}
) )
+36 -29
View File
@@ -23,25 +23,31 @@ from .crud import (
) )
from .models import CreateUserData, CreateUserWallet from .models import CreateUserData, CreateUserWallet
# Users
@usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK) @usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
async def api_usermanager_users(wallet: WalletTypeInfo = Depends(require_admin_key)): async def api_usermanager_users(
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
):
user_id = wallet.wallet.user user_id = wallet.wallet.user
return [user.dict() for user in await get_usermanager_users(user_id)] return [user.dict() for user in await get_usermanager_users(user_id)]
@usermanager_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK) @usermanager_ext.get(
async def api_usermanager_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)): "/api/v1/users/{user_id}",
status_code=HTTPStatus.OK,
dependencies=[Depends(get_key_type)],
)
async def api_usermanager_user(user_id):
user = await get_usermanager_user(user_id) user = await get_usermanager_user(user_id)
return user.dict() return user.dict() if user else None
@usermanager_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED) @usermanager_ext.post(
async def api_usermanager_users_create( "/api/v1/users",
data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type) status_code=HTTPStatus.CREATED,
): dependencies=[Depends(get_key_type)],
)
async def api_usermanager_users_create(data: CreateUserData):
user = await create_usermanager_user(data) user = await create_usermanager_user(data)
full = user.dict() full = user.dict()
full["wallets"] = [ full["wallets"] = [
@@ -50,11 +56,12 @@ async def api_usermanager_users_create(
return full return full
@usermanager_ext.delete("/api/v1/users/{user_id}") @usermanager_ext.delete(
"/api/v1/users/{user_id}", dependencies=[Depends(require_admin_key)]
)
async def api_usermanager_users_delete( async def api_usermanager_users_delete(
user_id, user_id,
delete_core: bool = Query(True), delete_core: bool = Query(True),
wallet: WalletTypeInfo = Depends(require_admin_key),
): ):
user = await get_usermanager_user(user_id) user = await get_usermanager_user(user_id)
if not user: if not user:
@@ -84,10 +91,8 @@ async def api_usermanager_activate_extension(
# Wallets # Wallets
@usermanager_ext.post("/api/v1/wallets") @usermanager_ext.post("/api/v1/wallets", dependencies=[Depends(get_key_type)])
async def api_usermanager_wallets_create( async def api_usermanager_wallets_create(data: CreateUserWallet):
data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type)
):
user = await create_usermanager_wallet( user = await create_usermanager_wallet(
user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id
) )
@@ -95,31 +100,33 @@ async def api_usermanager_wallets_create(
@usermanager_ext.get("/api/v1/wallets") @usermanager_ext.get("/api/v1/wallets")
async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(require_admin_key)): async def api_usermanager_wallets(
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
):
admin_id = wallet.wallet.user admin_id = wallet.wallet.user
return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)] return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)]
@usermanager_ext.get("/api/v1/transactions/{wallet_id}") @usermanager_ext.get(
async def api_usermanager_wallet_transactions( "/api/v1/transactions/{wallet_id}", dependencies=[Depends(get_key_type)]
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) )
): async def api_usermanager_wallet_transactions(wallet_id):
return await get_usermanager_wallet_transactions(wallet_id) return await get_usermanager_wallet_transactions(wallet_id)
@usermanager_ext.get("/api/v1/wallets/{user_id}") @usermanager_ext.get(
async def api_usermanager_users_wallets( "/api/v1/wallets/{user_id}", dependencies=[Depends(require_admin_key)]
user_id, wallet: WalletTypeInfo = Depends(require_admin_key) )
): async def api_usermanager_users_wallets(user_id):
return [ return [
s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id) s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id)
] ]
@usermanager_ext.delete("/api/v1/wallets/{wallet_id}") @usermanager_ext.delete(
async def api_usermanager_wallets_delete( "/api/v1/wallets/{wallet_id}", dependencies=[Depends(require_admin_key)]
wallet_id, wallet: WalletTypeInfo = Depends(require_admin_key) )
): async def api_usermanager_wallets_delete(wallet_id):
get_wallet = await get_usermanager_wallet(wallet_id) get_wallet = await get_usermanager_wallet(wallet_id)
if not get_wallet: if not get_wallet:
raise HTTPException( raise HTTPException(
+5 -1
View File
@@ -27,9 +27,11 @@ async def create_withdraw_link(
open_time, open_time,
usescsv, usescsv,
webhook_url, webhook_url,
webhook_headers,
webhook_body,
custom_url custom_url
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
link_id, link_id,
@@ -45,6 +47,8 @@ async def create_withdraw_link(
int(datetime.now().timestamp()) + data.wait_time, int(datetime.now().timestamp()) + data.wait_time,
usescsv, usescsv,
data.webhook_url, data.webhook_url,
data.webhook_headers,
data.webhook_body,
data.custom_url, data.custom_url,
), ),
) )
+30 -6
View File
@@ -11,6 +11,7 @@ from loguru import logger
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from lnbits.core.crud import update_payment_extra
from lnbits.core.services import pay_invoice from lnbits.core.services import pay_invoice
from . import withdraw_ext from . import withdraw_ext
@@ -44,7 +45,11 @@ async def api_lnurl_response(request: Request, unique_hash):
"minWithdrawable": link.min_withdrawable * 1000, "minWithdrawable": link.min_withdrawable * 1000,
"maxWithdrawable": link.max_withdrawable * 1000, "maxWithdrawable": link.max_withdrawable * 1000,
"defaultDescription": link.title, "defaultDescription": link.title,
"webhook_url": link.webhook_url,
"webhook_headers": link.webhook_headers,
"webhook_body": link.webhook_body,
} }
return json.dumps(withdrawResponse) return json.dumps(withdrawResponse)
@@ -56,7 +61,7 @@ async def api_lnurl_response(request: Request, unique_hash):
name="withdraw.api_lnurl_callback", name="withdraw.api_lnurl_callback",
summary="lnurl withdraw callback", summary="lnurl withdraw callback",
description=""" description="""
This enpoints allows you to put unique_hash, k1 This endpoints allows you to put unique_hash, k1
and a payment_request to get your payment_request paid. and a payment_request to get your payment_request paid.
""", """,
response_description="JSON with status", response_description="JSON with status",
@@ -143,18 +148,37 @@ async def api_lnurl_callback(
if link.webhook_url: if link.webhook_url:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
r = await client.post( kwargs = {
link.webhook_url, "json": {
json={
"payment_hash": payment_hash, "payment_hash": payment_hash,
"payment_request": payment_request, "payment_request": payment_request,
"lnurlw": link.id, "lnurlw": link.id,
}, },
timeout=40, "timeout": 40,
}
if link.webhook_body:
kwargs["json"]["body"] = json.loads(link.webhook_body)
if link.webhook_headers:
kwargs["headers"] = json.loads(link.webhook_headers)
r: httpx.Response = await client.post(link.webhook_url, **kwargs)
await update_payment_extra(
payment_hash,
{
"wh_success": r.is_success,
"wh_message": r.reason_phrase,
"wh_response": r.text,
},
) )
except Exception as exc: except Exception as exc:
# webhook fails shouldn't cause the lnurlw to fail since invoice is already paid # webhook fails shouldn't cause the lnurlw to fail since invoice is already paid
logger.error("Caught exception when dispatching webhook url:", exc) logger.error(
"Caught exception when dispatching webhook url: " + str(exc)
)
await update_payment_extra(
payment_hash,
{"wh_success": False, "wh_message": str(exc)},
)
return {"status": "OK"} return {"status": "OK"}
+10
View File
@@ -122,3 +122,13 @@ async def m005_add_custom_print_design(db):
Adds custom print design Adds custom print design
""" """
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN custom_url TEXT;") await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN custom_url TEXT;")
async def m006_webhook_headers_and_body(db):
"""
Add headers and body to webhooks
"""
await db.execute(
"ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_headers TEXT;"
)
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_body TEXT;")
+4
View File
@@ -16,6 +16,8 @@ class CreateWithdrawData(BaseModel):
wait_time: int = Query(..., ge=1) wait_time: int = Query(..., ge=1)
is_unique: bool is_unique: bool
webhook_url: str = Query(None) webhook_url: str = Query(None)
webhook_headers: str = Query(None)
webhook_body: str = Query(None)
custom_url: str = Query(None) custom_url: str = Query(None)
@@ -35,6 +37,8 @@ class WithdrawLink(BaseModel):
usescsv: str = Query(None) usescsv: str = Query(None)
number: int = Query(0) number: int = Query(0)
webhook_url: str = Query(None) webhook_url: str = Query(None)
webhook_headers: str = Query(None)
webhook_body: str = Query(None)
custom_url: str = Query(None) custom_url: str = Query(None)
@property @property
+25 -12
View File
@@ -63,7 +63,8 @@ new Vue({
secondMultiplierOptions: ['seconds', 'minutes', 'hours'], secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
data: { data: {
is_unique: false, is_unique: false,
use_custom: false use_custom: false,
has_webhook: false
} }
}, },
simpleformDialog: { simpleformDialog: {
@@ -188,23 +189,35 @@ new Vue({
}, },
updateWithdrawLink: function (wallet, data) { updateWithdrawLink: function (wallet, data) {
var self = this var self = this
const body = _.pick(
data,
'title',
'min_withdrawable',
'max_withdrawable',
'uses',
'wait_time',
'is_unique',
'webhook_url',
'webhook_headers',
'webhook_body',
'custom_url'
)
if (data.has_webhook) {
body = {
...body,
webhook_url: data.webhook_url,
webhook_headers: data.webhook_headers,
webhook_body: data.webhook_body
}
}
LNbits.api LNbits.api
.request( .request(
'PUT', 'PUT',
'/withdraw/api/v1/links/' + data.id, '/withdraw/api/v1/links/' + data.id,
wallet.adminkey, wallet.adminkey,
_.pick( body
data,
'title',
'min_withdrawable',
'max_withdrawable',
'uses',
'wait_time',
'is_unique',
'webhook_url',
'custom_url'
)
) )
.then(function (response) { .then(function (response) {
self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) {
@@ -209,7 +209,13 @@
</q-select> </q-select>
</div> </div>
</div> </div>
<q-toggle
label="Webhook"
color="secodary"
v-model="formDialog.data.has_webhook"
></q-toggle>
<q-input <q-input
v-if="formDialog.data.has_webhook"
filled filled
dense dense
v-model="formDialog.data.webhook_url" v-model="formDialog.data.webhook_url"
@@ -217,6 +223,24 @@
label="Webhook URL (optional)" label="Webhook URL (optional)"
hint="A URL to be called whenever this link gets used." hint="A URL to be called whenever this link gets used."
></q-input> ></q-input>
<q-input
v-if="formDialog.data.has_webhook"
filled
dense
v-model="formDialog.data.webhook_headers"
type="text"
label="Webhook Headers (optional)"
hint="Custom data as JSON string, send headers along with the webhook."
></q-input>
<q-input
v-if="formDialog.data.has_webhook"
filled
dense
v-model="formDialog.data.webhook_body"
type="text"
label="Webhook custom data (optional)"
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
></q-input>
<q-list> <q-list>
<q-item tag="label" class="rounded-borders"> <q-item tag="label" class="rounded-borders">
<q-item-section avatar> <q-item-section avatar>
-1
View File
@@ -110,7 +110,6 @@ exclude = """(?x)(
| ^lnbits/extensions/streamalerts. | ^lnbits/extensions/streamalerts.
| ^lnbits/extensions/tipjar. | ^lnbits/extensions/tipjar.
| ^lnbits/extensions/tpos. | ^lnbits/extensions/tpos.
| ^lnbits/extensions/usermanager.
| ^lnbits/extensions/watchonly. | ^lnbits/extensions/watchonly.
| ^lnbits/extensions/withdraw. | ^lnbits/extensions/withdraw.
| ^lnbits/wallets/lnd_grpc_files. | ^lnbits/wallets/lnd_grpc_files.