Compare commits

..
470 Commits
Author SHA1 Message Date
dni ⚡andGitHub 793c7c45ed chore: update version to v1.3.0-rc2 (#3287) 2025-07-21 11:32:53 +03:00
dni ⚡andGitHub ff535ae065 feat: add get_pr_from_lnurl to lnurl services (#3286) 2025-07-21 10:28:14 +02:00
dni ⚡andGitHub 01fdc1defb fix: frontend withdraw error message (#3282) 2025-07-21 10:34:50 +03:00
dce2bfb440 [feat] User notifications backend (#3280)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-07-17 16:11:40 +02:00
dni ⚡andGitHub a36ab2d408 feat: update lnurl lib refactor lnurl endpoints (#3271) 2025-07-17 17:03:44 +03:00
dni ⚡andGitHub 98f732fb6f chore: update fastapi an starlette (#3277) 2025-07-17 16:41:54 +03:00
dni ⚡andGitHub 65864cb216 chore: use modern websockets instead of legacy (#3278) 2025-07-17 15:02:29 +03:00
dni ⚡andGitHub 114e7b8c3c chore: update to version v1.3.0-rc1 (#3273) 2025-07-17 14:59:12 +03:00
dni ⚡andGitHub fbfab880f3 chore: replace passlib with bcrypt (#3279) 2025-07-17 14:58:43 +03:00
dni ⚡andGitHub 133afff8ee fix: catch init fiat providers (#3281) 2025-07-17 14:58:03 +03:00
dni ⚡andGitHub 851c952c6d chore: update vue-i18n dependabot (#3276) 2025-07-17 12:23:55 +03:00
dni ⚡andGitHub 8b29148085 fix: frontend bgimagechoice can be null (#3270) 2025-07-15 12:36:39 +03:00
dni ⚡ ad48092549 chore: bundle 2025-07-15 11:16:42 +02:00
ee449ca612 chore: udate v1.2.1 fi.js (#3268)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:15:19 +02:00
Vlad Stananddni ⚡ 73f45ae859 feat: better server start logging (#3266) 2025-07-15 11:15:18 +02:00
Vlad Stananddni ⚡ 094a9fc1a1 [feat] UI for user notifications (#3267) 2025-07-15 11:15:17 +02:00
dni ⚡ 5e9919c5c5 feat: toggle holdinvoice depending on fundingsource Feature (#3262) 2025-07-15 11:15:16 +02:00
f91c933919 feat: HodlInvoices (Rebase) (#2869)
Co-authored-by: pseudozach <git@pseudozach.com>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:15:14 +02:00
dni ⚡ 6e9f451419 feat: introduce self.features to wallets, refactor feature nodemanager (#3260) 2025-07-15 11:15:13 +02:00
dni ⚡ 256a8098c6 chore: get rid of mypy notes and typecheck more functions (#3257) 2025-07-15 11:15:12 +02:00
Arcanddni ⚡ 1a02095758 refactor: theme options, includes fix bg on firefox with dialog (#3247)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-15 11:15:10 +02:00
Tiago Vasconcelosanddni ⚡ ec5b7493b3 fix: default theme settings (#3256) 2025-07-15 11:14:33 +02:00
cdc1a6dc8f feat: Add support for the new CLNRest as a funding source (#2778)
Co-authored-by: Amperstrand <amperstrand@localhost>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:32 +02:00
Arcanddni ⚡ 7d573ef859 chore: tooltip for ad (#3255) 2025-07-15 11:14:30 +02:00
Vlad Stananddni ⚡ 4bd2a4fba9 chore: upgrade protobuf (#3252) 2025-07-15 11:14:29 +02:00
dni ⚡ 209c251fb6 fix: update and fixes for BreezWallet (#3246) 2025-07-15 11:14:28 +02:00
dni ⚡ 69a7ef74fd CI: fix using all optional dependecies (#3251) 2025-07-15 11:14:27 +02:00
dni ⚡andVlad Stan 35e2c4b0a7 fix: update success status (#3244)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:25 +02:00
dni ⚡ 2c6dce808e feat: run_interval in tasks and refactor check_pending_payments (#3245) 2025-07-15 11:14:24 +02:00
Vlad Stananddni ⚡ fa89313e6f [fix] bandit assert warnings (#3243)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-15 11:14:23 +02:00
Vlad Stananddni ⚡ 76ecf113c3 [fix] bandit sql warnings (#3242) 2025-07-15 11:14:22 +02:00
Vlad Stananddni ⚡ e0749e186e [fix] Small bandit changes (#3241) 2025-07-15 11:14:20 +02:00
0529ee2835 feat: add Breez *Liquid* SDK funding source (#2681)
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:19 +02:00
Vlad Stananddni ⚡ 09b91f9f79 refactor: extract create_payment_request (#3240) 2025-07-15 11:14:18 +02:00
dni ⚡andVlad Stan 90ab642dcd fix: phoenixd pending payments (#3075)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:16 +02:00
88cf1ac853 feat: Allow custom memo on pay invoice (#3236)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:15 +02:00
Vlad Stananddni ⚡ 9aa2194d94 refactor: use while settings.lnbits_running instead of while True 2025-07-15 11:14:04 +02:00
dni ⚡ ba0f7a01ef chore: reduce max complexity lint (#3230) 2025-07-15 11:14:03 +02:00
Tiago Vasconcelosanddni ⚡ 5765ea0276 [Fix] Display fee in sats (#3235) 2025-07-15 11:14:02 +02:00
Arcanddni ⚡ 88b01e6079 feat: theme fix for elsal, cyber, freedom (#3259) 2025-07-11 12:39:03 +02:00
Arcanddni ⚡ b0d0901969 refactor: theme options, includes fix bg on firefox with dialog (#3247)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-11 12:39:02 +02:00
dni ⚡ 6563610b6c chore: update to version 1.2.1 2025-07-11 12:38:33 +02:00
dni ⚡ c4aa0b598c chore: update to version 1.2.0 2025-07-08 10:22:22 +02:00
dni ⚡ aab76e691d chore: update to version v1.2.0-rc3 (#3231) 2025-07-08 10:17:30 +02:00
Vlad Stananddni ⚡ 3ed30be93e fix: condition for available, but not installed extension (#3232) 2025-07-08 10:17:29 +02:00
Vlad Stananddni ⚡ 37f99e7f96 doc: LNBITS_EXTENSIONS_PATH (#3229) 2025-07-08 10:17:28 +02:00
dni ⚡ a16078a6ba chore: update python packages + formatting + cleanup (#3221) 2025-07-08 10:17:27 +02:00
dni ⚡ c4c03d96a3 test: add regtest testcase for no route failure (#3189) 2025-07-08 10:17:25 +02:00
Vlad Stananddni ⚡ ab3248da15 fix: url_for should not return extension upgrades temp paths (#3227) 2025-07-08 10:17:24 +02:00
dni ⚡ b4b37cd733 fix: circular import on handle_fiat_payments (#3226) 2025-07-08 10:17:23 +02:00
dni ⚡ 26eb7a449c feat: add internal_invoice_queue_put to get rid of dynamic import (#3223) 2025-07-08 10:17:21 +02:00
Vlad Stananddni ⚡ 695e9b6471 [feat] Add stripe payments (#3184) 2025-07-08 10:17:19 +02:00
blackcoffeexbtanddni ⚡ 5c9511ccfe Label tweaks (#3224) 2025-07-08 10:17:18 +02:00
Vlad Stananddni ⚡ c7b7832a88 feat: add external id for users (#3219) 2025-07-08 10:17:16 +02:00
dni ⚡andGitHub ff24847980 feat: add nostr-sdk library (#3217) 2025-06-25 13:58:20 +03:00
659ec31edd CI: lnbits-boltz docker, for those who want instant payments + boltz funding source UX tweak (#3192)
Co-authored-by: jackstar12 <62219658+jackstar12@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-06-25 12:20:46 +02:00
edb7d66efc feat: Add trimmed mean filtering for exchange rate outlier detection (#3206)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-06-25 13:16:00 +03:00
efcc5e2148 feat: improved nostr pubkey field label & tooltip and set password tooltip (#3205)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-06-25 12:06:42 +02:00
dni ⚡andGitHub 55889abd88 chore: update poetry, disable nix workflow and python package updates (#3213) 2025-06-24 18:09:48 +02:00
dni ⚡andGitHub 063aa4fd06 chore: update to lnbits version 1.2.0-rc2 (#3216) 2025-06-24 17:38:58 +02:00
Tiago VasconcelosandGitHub 7c0a955b30 fix: error handling (#3214) 2025-06-24 18:27:28 +03:00
dni ⚡andGitHub b1a09af82c chore: update py version in lnbits.sh (#3215) 2025-06-24 18:25:33 +03:00
dni ⚡andGitHub 6d2f39d390 fix: rounding errors on eclair total balance (#3196) 2025-06-24 16:09:53 +02:00
Tiago VasconcelosandGitHub 18496f0dd9 feat: add QR code for wallet keys (#3198) 2025-06-23 14:04:21 +03:00
Tiago VasconcelosandGitHub acf6c732ad [feat] Add payment status filters (#3203) 2025-06-20 14:45:12 +03:00
Vlad StanandGitHub b39bd257e0 fix: wallets navigation (#3208) 2025-06-20 13:45:08 +03:00
dni ⚡andGitHub 9aeecd4dc0 chore: update to version v1.2.0-rc1 (#3202) 2025-06-17 20:05:22 +03:00
e750165cc3 Allow custom text and icon for Keycloak (#3181)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-06-06 13:20:55 +03:00
dni ⚡andGitHub 77906bc817 feat: more verbose aes decrypt function (#3177) 2025-05-30 18:49:11 +03:00
Vlad StanandGitHub 63e728710d fix: accept soft deleted wallets (#3179) 2025-05-30 18:48:23 +03:00
blackcoffeexbtandGitHub 27fd510142 Add funding sources comparison table (#3183) 2025-05-29 13:45:08 +03:00
Vlad StanandGitHub e6de66e1b1 fix: handle node absent in 1ml.com (#3180) 2025-05-27 17:02:15 +03:00
dni ⚡andGitHub 4071925f65 feat: mask unexcepted error and add a exception id (#3178) 2025-05-27 12:37:38 +03:00
dni ⚡andGitHub 3c4d186dba feat: add urlsafe enc to lnbits-cli (#3173) 2025-05-27 11:51:26 +03:00
Vlad StanandGitHub 56aebb9d8f feat: better service fee payment memo (#3176) 2025-05-27 11:50:39 +03:00
Vlad StanandGitHub beee24bd92 [feat] ui support for high number of wallets and payments (#3174) 2025-05-27 11:41:25 +03:00
Vlad StanandGitHub 375b95c004 fix: allow ports for domains (#3171) 2025-05-26 11:35:02 +03:00
Tiago VasconcelosandGitHub 5345ccaf4e [Fix] QR readability (#3163) 2025-05-20 13:06:53 +03:00
ArcandGitHub efc5233399 fix: frontend scroll area on admin settings (#3162) 2025-05-20 11:52:07 +03:00
ArcandGitHub e4d09c6d12 docs: passing vars to appimage (#3164) 2025-05-20 11:51:52 +03:00
Vlad StanandGitHub 7d0545dae1 [fix] timezone for payment list (#3165) 2025-05-20 11:51:32 +03:00
00fccf513e feat: Add Strike Wallet Integration (#3150)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-05-20 11:51:05 +03:00
dni ⚡andGitHub 34b8490a2d test: conftest smaller funding amounts (#3167) 2025-05-20 11:25:08 +03:00
dni ⚡andGitHub cbbba5c4c7 fix: regtest nodemanager lndrest issue (#3166) 2025-05-20 09:23:59 +02:00
dni ⚡andGitHub 4a0ef7fa1a feat: add ssl proxy settings to docker and .env.example (#3161) 2025-05-19 12:47:30 +01:00
dni ⚡andGitHub cd8804daca chore: update to version v1.1.0 (#3157) 2025-05-13 13:34:47 +02:00
39e4fa724a test: additional cases for internal payments (#3155)
Co-authored-by: dni  <office@dnilabs.com>
2025-05-13 13:48:47 +03:00
dni ⚡andGitHub 3b350858c7 refactor: untangle lnd's macaroon encryption with AESCipher class (#3152) 2025-05-13 12:07:38 +02:00
7bea591879 feat: dont reset superuser on delete settings endpoint (#3065)
Co-authored-by: dni  <office@dnilabs.com>
2025-05-13 12:52:59 +03:00
Vlad StanandGitHub f74fcea35b fix: safe json conversion (#3148) 2025-05-13 12:51:58 +03:00
ceb8203353 refactor: render variables for jinja, components and window vars (#3014)
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-05-13 11:49:46 +02:00
SatandGitHub 7b29223e95 feat: add verify_preimage utility function (#3153) 2025-05-13 12:47:08 +03:00
Se7enZandGitHub bd19d78834 build: add POETRY_INSTALL_ARGS build argument to Dockerfile (#3149) 2025-05-11 04:47:45 +02:00
Tiago VasconcelosandGitHub 8458b4d84b fix: do not ask for NFC if not needed (#3145) 2025-05-05 22:23:32 +02:00
Vlad StanandGitHub f92ae8bae1 doc: add time unit to payment_wait_time (#3144) 2025-05-05 13:39:00 +03:00
Vlad StanandGitHub 3529f9152f fix: better differentiation between UNAUTHORIZED and FORBIDDEN (#3139) 2025-05-05 10:55:58 +02:00
dni ⚡andGitHub 6a9089fd98 fix: lnd/lndrest failed canceled/expired invoices (#3143) 2025-05-05 11:31:11 +03:00
tlindiandGitHub 5818c3c2ba [doc] Update installation.md to match v1.0.x requirements (#3107) 2025-05-04 09:37:03 +02:00
dni ⚡andGitHub a38de94e49 chore: update to v1.0.1 (#3137) 2025-05-02 17:01:20 +03:00
tlindiandGitHub b4801ce0ab Update fi.js Password reset request (+fix "more") (#3138) 2025-05-02 10:46:10 +03:00
51bf344d65 [FIX] Fix database compatibility issue in search query builder for payments filter (#3090)
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-05-02 10:45:35 +03:00
Vlad StanandGitHub 32cbf16d91 fix: _bcrypt.__about__.__version__ log warning (#3136) 2025-04-30 20:44:55 +03:00
c4d0540e76 feat: preimages for incoming payments, fundingsource saves preimage on create_invoice (#3085)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-04-30 11:03:19 +02:00
Vlad StanandGitHub f8b3644029 fix: auto redirect to home page when 401 (#3131) 2025-04-29 15:28:04 +03:00
dni ⚡andGitHub c2fb45d640 docs: add notes about freeBSD install (#3124) 2025-04-29 13:55:07 +03:00
Vlad StanandGitHub e339bb6181 [feat] fetch all payments for user (#3132) 2025-04-29 13:52:07 +03:00
Vlad StanandGitHub 2dee26b728 fix: set explicit max_age for cookies (#3133) 2025-04-29 11:16:37 +03:00
dni ⚡andGitHub d774c7a742 security: update package h11 (#3127) 2025-04-28 15:32:32 +02:00
Vlad StanandGitHub 6c2b312c92 fix: webhook call on invoice pay (#3119) 2025-04-28 12:28:27 +03:00
iwarpandGitHub 56c8783d9a fix: Normalize fee_reserve amount value (#3125) 2025-04-28 12:27:40 +03:00
dni ⚡andGitHub ffecd03c4b refactor: fundingsource Invoice-, PaymentResponses (#3089) 2025-04-25 11:52:54 +02:00
Vlad StanandGitHub 94d5f37723 fix: mask for fiat amount (#3118) 2025-04-24 08:11:39 +02:00
Vlad StanandGitHub d89bd7409d fix: use UTC for fromNow() (#3105) 2025-04-17 10:05:05 +03:00
iwarpandGitHub b48489ef32 fix: Explicitly convert lndrest fee_msat to int (#3116) 2025-04-17 09:48:06 +03:00
Vlad StanandGitHub e686ecda51 feat: i18n reset password message (#3106) 2025-04-14 13:25:41 +03:00
Vlad StanandGitHub 23b4c2cc16 [fix] offset naive dates error (#3104) 2025-04-14 13:21:16 +03:00
Tiago VasconcelosandGitHub 1bb29cf0b1 bug: add the defaults else it fails (#3099) 2025-04-12 09:47:00 +02:00
dni ⚡andGitHub 5714848434 feat: install lnbits.sh bash script (#2684) (#3101) 2025-04-10 12:28:08 +02:00
dni ⚡andGitHub f87e5d4372 chore: update LNbits to version v1.0.0 (#3100) 2025-04-10 12:09:13 +02:00
Tiago VasconcelosandGitHub a0502f1676 [Fix] - Bug on switching wallets and payment details (#3094) 2025-04-10 10:02:35 +01:00
Vlad StanandGitHub f6c8a308dc feat: create new user from the command line (#3098) 2025-04-09 14:01:34 +02:00
dni ⚡andGitHub 6df3933c1b ci: trigger release on rc's > 9 (#3088) 2025-04-03 13:12:19 +02:00
dni ⚡andGitHub ffef085fa7 chore: update to v1.0.0-rc10 (#3087) 2025-04-03 12:55:30 +02:00
tlindiandGitHub 913991c72f Update fi.js-1.0.0-rc10-tobe (#3086) 2025-04-03 10:31:49 +03:00
dni ⚡andGitHub 6f611461f2 fix: FakeWallet, bolt11 payment_secret is not the preimage (#3084) 2025-04-02 18:46:24 +03:00
Tiago VasconcelosandGitHub 681385e2a2 fix dialog consistency (#3082) 2025-04-02 16:03:40 +02:00
dni ⚡andGitHub 883d52c303 fix: phoenixd uses sat instead of msat for pay_invoice (#3083) 2025-04-02 16:49:59 +03:00
Vlad StanandGitHub 1058461882 fix: include node balance in server status (#3079) 2025-04-02 15:31:47 +03:00
Vlad StanandGitHub 3a7a88857a fix: missing label (#3081) 2025-04-02 14:51:15 +03:00
Vlad StanandGitHub 6834b5e00f feat: do not allow regular admins to edit super user (#3077) 2025-04-01 14:01:02 +03:00
Vlad StanandGitHub 10093bb465 feat: show wallet payments on search (#3076) 2025-04-01 12:41:10 +02:00
dni ⚡andGitHub 1323a2005b feat: cleanup on library dir creation and upload endpoints (#3069) 2025-04-01 10:27:11 +03:00
Vlad StanandGitHub bafb4ddf75 fix: allow admin to view payments in deleted wallets (#3074) 2025-03-31 17:18:32 +03:00
Vlad StanandGitHub aa050eaf49 fix: make parallel calls for exchages (#3073) 2025-03-31 14:44:36 +03:00
ArcandGitHub 0c76efa2b9 Add ws link to curl examples (#3063) 2025-03-31 12:58:26 +03:00
PatMulliganandGitHub 681730a4af [CHORE] add responsive text scaling with clamp() for wallet balance display (#3071) 2025-03-31 11:59:58 +03:00
ArcandGitHub 63adcb6780 Adds image library for admins (#3066) 2025-03-27 11:16:20 +02:00
dni ⚡andGitHub 35f7821183 fix: make swagger understand the access token HTTPBearer (#3068) 2025-03-27 09:14:42 +00:00
Vlad StanandGitHub b185d9585c fix: add default extensions when a user is created (#3061) 2025-03-22 23:54:15 +00:00
ArcandGitHub 30a8d88ada Remove LNbits UI elements (#3064) 2025-03-22 21:08:26 +00:00
ArcandGitHub f7e984198b Revert "tweak: fix for ws payment" (#3060) 2025-03-21 14:03:52 +02:00
ArcandGitHub eb09e95e88 tweak: fix for ws payment (#3057) 2025-03-20 23:51:10 +00:00
Tiago VasconcelosandGitHub a883e2c7d8 hide 'Runs On' scroll if title is not LNbits (#3053) 2025-03-18 10:19:10 +01:00
Tiago VasconcelosandGitHub e06abb52ce [Fix] default to sats if wallet not fakewallet (#3055) 2025-03-18 10:17:47 +01:00
tlindiandGitHub 9f64b5349d Dev update fi.js again (#3052) 2025-03-18 11:07:37 +02:00
ArcandGitHub a6bbddce41 tweak: use image that works for fav and qr as default (#3051) 2025-03-14 10:48:58 +01:00
Tiago VasconcelosandGitHub adb9d24673 [Fix] Set default chart text color (#3048)
* fix: set default chart text
white when darkMode else black
2025-03-12 17:36:11 +01:00
Tiago VasconcelosandGitHub dc85f26964 fix: qr scanner button was missing the method (#3046) 2025-03-12 16:09:53 +01:00
Tiago VasconcelosandGitHub 14153b4a8f [Fix] Remove references and warnings for BETA (#3047)
* fix: remove BETA references/warnings
2025-03-12 16:09:18 +01:00
Vlad StanandGitHub b8a5f3942c [fix] limit the size of the invoice description to 640 characters (#3045) 2025-03-12 16:29:29 +02:00
dni ⚡andGitHub 475ae5736e chore: update to v1.0.0-rc9 (#3043) 2025-03-12 12:29:01 +01:00
PatMulliganandGitHub d7f0f4da0c create backwards compatibility for <=v1.0 (#3041)
in v0.12.12, the key is "payment_request" instead of the new "bolt11"
2025-03-12 12:27:24 +01:00
Tiago VasconcelosandGitHub f7b3444be4 fix: unfuck the swap mess (#3023) 2025-03-12 12:14:20 +01:00
ArcandGitHub 28e32a54a4 tweak: use qr code image for favicon as well (#3031)
use qr code image for favicon as well
2025-03-12 12:12:43 +01:00
ArcandGitHub 1833adc0a1 Tweak: moved custom denomination to fakewallet funding source (#3030) 2025-03-12 12:10:58 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
b759ec7468 chore(deps): bump axios from 1.7.7 to 1.8.2 (#3042)
* chore(deps): bump axios from 1.7.7 to 1.8.2

Bumps [axios](https://github.com/axios/axios) from 1.7.7 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.7...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* bundle

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:10:27 +01:00
2661d12ff1 tweak: fixes service fee badge (#3029)
* tweak: fixes service fee badge

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:08:08 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
ed419f27ac chore(deps): bump vue-i18n from 10.0.5 to 10.0.6 (#3022)
* chore(deps): bump vue-i18n from 10.0.5 to 10.0.6

Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 10.0.5 to 10.0.6.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v10.0.6/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* bundle

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:01:18 +01:00
dni ⚡andGitHub c3efd48108 chore: use latest 1.X poetry version (#3039) 2025-03-11 09:54:36 +01:00
ArcandGitHub 4a28c22d79 tweak: fix 'test email' btn (#3026) 2025-03-10 18:37:11 +00:00
ArcandGitHub 0d0eb36de4 tweak: fix funding source hide (#3027)
added to title rather than all the inputs
2025-03-10 18:36:48 +00:00
ArcandGitHub 2aa73bfe75 tweak: punchy bitcoin theme (#3028) 2025-03-10 18:36:23 +00:00
Vlad StanandGitHub ebd080192c feat: return payment status in the websocket response (#3025) 2025-03-10 12:48:40 +02:00
Tiago VasconcelosandGitHub 41abe63f18 hide text on small screen (#3021) 2025-03-07 14:23:46 +00:00
dni ⚡andGitHub f61471b0f2 feat: add test email and improve on email notifications (#3018) 2025-03-05 09:53:40 +02:00
Vlad StanandGitHub 9bd037b6e7 [fix] settings cleanup (#3013) 2025-03-04 16:30:54 +02:00
a4ca88b6a4 tweak: dont show full id on wallet select box (#3017)
* tweak: dont show full id on wallet select box
* chore bundle

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-04 11:23:02 +01:00
ArcandGitHub f2b9aafc51 Tweak: Changed naming for password area (#3016) 2025-03-04 11:13:41 +01:00
Vlad StanandGitHub ec77a00f49 feat: only check the current page (#3015) 2025-03-03 16:28:55 +02:00
991e0db50b Feat: email notifications (#3007)
* Feat: email notifications / email db backup

Because not using a simple smtp connection is silly

* Added form

* broken, and annoying bug

Why wont the input display?!?!?

* make

* Encourage protonmail use, because its great.

* feat: small UI polishing

* chore: make bundle

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-03-03 12:59:08 +00:00
fe9b62e8a8 Fix: don't show register if new users not allowed (#3010)
* enforce allow new user flag on UI
* chore: bundle
* ensure we get a boolean

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-03 12:53:58 +01:00
dni ⚡andGitHub c5964436b5 fix: python versions were defined incorrectly and remove 3.9 (#3006)
* chore: update requirements to python3.10
* chore: format RUF007 rule for py310
* wrongly specified python version
python3.13 there is a greenlet build error
2025-03-03 12:53:30 +01:00
Vlad StanandGitHub 4ce14e2312 fix: rate lmitter init (#3011) 2025-02-28 12:50:50 +01:00
PatMulliganandGitHub 7021add193 Feat (crypto): Add URL-safe encoding option to AESCipher (#2984) 2025-02-28 11:17:53 +02:00
Tiago VasconcelosandGitHub 4be01a405c compare not assign (#3009)
fix missing = sign
2025-02-28 09:31:49 +02:00
Vlad StanandGitHub ebe9ff700f [fix] admin errors (#3004) 2025-02-27 11:00:15 +02:00
dni ⚡andGitHub a3619d40c6 fix: paying an invoice does not send a webhook. #2472 (#2999)
* fix: paying an invoice does not send a webhook. #2472
closes #2472
2025-02-27 09:23:49 +01:00
Vlad StanandGitHub 048aff3db4 [fix]: various issues discovered during testing (#3002)
* fix: prevent invalid calls to exchange APIs
* fix: audit conditions
* fix: audit filtering
2025-02-27 07:45:29 +01:00
Tiago VasconcelosandGitHub 89d85a0d7e [Fix] Denomination 3 characters (#3001)
* fix: denomination must be 3 chars ISO
2025-02-26 16:34:45 +01:00
5aa1f9b0f8 [feat] filter payments in wallet (#2997)
* fix: search payments
* fix rogue button
* feat: UI polishing
* feat: better charts

---------

Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2025-02-26 11:34:57 +01:00
Tiago VasconcelosandGitHub 5dc1705fa6 Fix a typo in reset password logic (#2998) 2025-02-25 13:52:03 +02:00
ArcandGitHub 0fc657c55c tweak: embit needs to be declared (#2996) 2025-02-24 15:05:20 +00:00
dni ⚡andGitHub 8641b34a3d chore: update lnbits version to 1.0.0-rc8 (#2995) 2025-02-24 14:53:52 +01:00
Vlad StanandGitHub 30cf6913af [feat]: invoice amount settings (#2990) 2025-02-24 14:45:17 +02:00
ArcandGitHub 10682cf736 tweak: checkout main not needed (#2993) 2025-02-24 12:26:42 +00:00
ArcandGitHub ee725846c5 tweak: just to make it obv port can be chnaged (#2992) 2025-02-24 12:19:42 +00:00
ArcandGitHub 69b31b1480 added libfuse2 to guide for fresh vps folks (#2991) 2025-02-24 12:15:29 +00:00
Tiago VasconcelosandGitHub 73a57ecf97 fix: payments filter (#2989) 2025-02-24 13:53:18 +02:00
dni ⚡andGitHub ab0f52d811 feat: add label for wallet fiat tracking (#2988) 2025-02-24 12:20:40 +01:00
dni ⚡andGitHub 8c59f3f4ae feat: add python-multipart for uploads (#2873) 2025-02-24 12:20:34 +02:00
ArcandGitHub 1bfb09ddab fix: default hide funding inputs (#2987)
* fix: default hide funding inputs

To protect admins from over the shoulder attackers, and the attackers from chipping away at their own morality.

* Remove need for function
2025-02-23 19:28:23 +00:00
ArcandGitHub 2af5ac9678 fix: wallet balance format (#2985) 2025-02-23 01:09:28 +00:00
4511891297 feat: modernize error page (#2949)
* can't figure this out!

* raise here so it gets picked up by exceptions

* add few more info for error rendering

* add a few more checks for browser

not fail safe just one more layer

* cleaner error display

... hopefully

* add go to extension

* keep buttons add go to extension

* feat: identify extensions that are not installed

* fix: status code

* fix: full path

* add account/logout button if 401

prevent getting stuck

* fix: ext access

* fix user button

* fix: 404 page

* fix: json 404 response

* fix: dumb rendering

* fix: `/api` request always json

* fix: extension api path

* test: check regtest

* test: investgate

* something made ws slower?

* fix: change error code

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2025-02-23 00:54:43 +00:00
eeca7de10d Fake wallet fixes (#2983)
* Fake wallet fixes

LNBITS_DENOMINATION and LNBITS_DENOMINATION == 'sats' everywhere!

* added exception to "/api/v1/rate/{currency}"

* fix: create invoice dialog

* cleaning expansion items in wallet

* Revert "fix: create invoice dialog"

This reverts commit 2b550392ba080cad708519fede897891e5b9e686.

* fix fakewallet mask break

* Fix formatted amount for fakewallet

* fixed wallet balance for fakewallet

* Wow, actually cleaning code, win!

* more cleaning

* For Vlad

* more cleaning

* make

* fix for sats

* bundle issue

* fix overflow in drawer for fat wallets and stupid long names

* make

* fixed adds

* fixed ad scale

* fundle

* fix

---------

Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2025-02-23 00:23:36 +00:00
b06c16ed57 fix: pay_invoice timeout to prevent blocking (#2875)
* fix: make invoice non blocking

* fix with vlad

* wallet.js take pending into account

* fixup!

* add check payment to outgoing

* revert

* bundle

* fix: label

* fix: rebase change

* feat: configure pay invoice wait time

* fix: check payment button

* bundle

* fix: task in async context

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-02-23 00:02:39 +00:00
dni ⚡andGitHub 2f2a3b1092 bug: settings fundingtab errors (#2982)
funding tab was not working if you had node ui enabled
2025-02-21 13:46:53 +02:00
Vlad StanandGitHub 9fe63b49d6 feat: add normalize_path helper (#2981) 2025-02-21 11:04:19 +02:00
Tiago VasconcelosandGitHub 6754ffb9dc change icon for pending (#2980) 2025-02-21 09:58:38 +02:00
Tiago VasconcelosandGitHub 4afe6dbd6d Change UI for Pay Invoice Dialog (#2979) 2025-02-18 18:42:46 +02:00
Tiago VasconcelosandGitHub 0cd94e654e adds a blur to the transparent bg (#2978) 2025-02-18 16:44:59 +02:00
Vlad StanandGitHub d1692a18dd fix: extract tag from extra (#2977) 2025-02-18 10:11:56 +02:00
Vlad Stan 0305d740ee feat: update LNBITS_SITE_TAGLINE 2025-02-18 09:35:28 +02:00
Tiago VasconcelosandGitHub 05777ca953 add blink logo (#2976) 2025-02-18 09:01:31 +02:00
Vlad StanandGitHub 1e083856ed fix: nostr login (#2975) 2025-02-17 16:18:04 +02:00
Vlad StanandGitHub c08623277e Chart polish (#2973) 2025-02-17 14:30:16 +02:00
Vlad StanandGitHub 60de308c8f fix: logout on safari (#2971) 2025-02-17 12:07:02 +02:00
ArcandGitHub 2aebe72df8 Quasarfy drawer scroll (#2965) 2025-02-16 02:00:33 +00:00
ArcandGitHub 2bd7600bcf Tweak so long wallet names/balances do not break styling (#2964) 2025-02-15 20:34:14 +00:00
tlindiandGitHub e318f7679e Update fi.js (#2963) 2025-02-14 19:44:08 +02:00
Vlad StanandGitHub 0912d7d1c8 fix: wallet url (#2961) 2025-02-13 22:54:28 +02:00
Vlad StanandGitHub bfa23568e3 [fix] callback url validation (#2959) 2025-02-13 15:11:46 +02:00
Vlad StanandGitHub b76d8b5458 fix: keep upgrades out of codebase (#2957)
* fix: keep upgrades out of codebase

* chore: code format
2025-02-12 13:31:53 +00:00
ArcandGitHub 1cef614349 Revert "Another small one (#2954)" (#2958)
This reverts commit 15ba3c33a3.
2025-02-12 12:06:09 +00:00
Vlad StanandGitHub 16fbfd1c6f Fix: date grouping for older SQLite versions (#2955) 2025-02-12 13:23:38 +02:00
Tiago VasconcelosandGitHub 15ba3c33a3 Another small one (#2954) 2025-02-12 10:56:22 +02:00
Tiago VasconcelosandGitHub b33a1903e8 check for g.user (#2953)
only render this if user is set
2025-02-11 17:25:42 +02:00
Vlad StanandGitHub 2d41a1bed3 Wallet polish (#2942) 2025-02-11 15:19:41 +02:00
ArcandGitHub 4d490506f3 Payments added to chart (#2952) 2025-02-11 12:26:04 +00:00
ArcandGitHub ed3a54e57a tweak: fun themes (#2951) 2025-02-10 23:20:30 +00:00
ArcandGitHub 61e8d0059b BG image/gradient to light fix (#2945) 2025-02-10 20:37:08 +00:00
ArcandGitHub 4a640dc123 Removes profile dropdown for public pages (#2943)
Global theme works for public pages.
2025-02-10 20:34:44 +00:00
ArcandGitHub 1f4160fda5 Adds extension#<ext name> option to extensions url (#2947) 2025-02-10 20:33:22 +00:00
ArcandGitHub dc91fbea9b Tweak: Updated Poetry install to include headers and make copy/paste easier (#2948)
* Tweak: Adds $USER to poetry install guide

Means a copy/paste will work for most

* auto agree for easy copy/paste

* poetry install and export

* ensure correct headers

* whoops

* typo
2025-02-10 20:32:33 +00:00
ArcandGitHub 7eb021f43c Adds no-border to fix the install card (#2950) 2025-02-10 20:31:41 +00:00
Vlad StanandGitHub 69350849b1 [fix] small fixes for ext upgrades (#2941) 2025-02-10 10:52:22 +02:00
Vlad StanandGitHub 828261d696 [fix]: small fixes v1 on demo DB (#2940) 2025-02-10 10:25:33 +02:00
ArcandGitHub 90dd3cc73a Fixes setting theme from account (#2939)
* getting there

* working

* make
2025-02-08 00:01:14 +00:00
ArcandGitHub 10c60291d2 white theme that doesnt hurt eyes (#2938) 2025-02-07 18:24:33 +00:00
ArcandGitHub 9d2e52f694 Fixes switching back to wallet (#2937)
* tals fix

* switched border back

* make

* bundle
2025-02-07 18:13:17 +00:00
Tiago VasconcelosandGitHub 503a599341 Fix login with oauth (#2935) 2025-02-07 16:48:22 +02:00
Vlad StanandGitHub a8809ed5a4 feat: migrate in chunks (#2936) 2025-02-07 14:16:53 +02:00
Tiago VasconcelosandGitHub e134c5c7b9 Fixes claiming LNURL (#2924) 2025-02-07 11:41:06 +02:00
ArcandGitHub 0efb52664e Feat: Creates .AppImage a̶n̶d̶ .d̶m̶g̶ on release (#2920) 2025-02-07 11:17:55 +02:00
Tiago VasconcelosandGitHub 53b00c6d41 UI improvements (#2926) 2025-02-07 10:09:15 +02:00
Tiago VasconcelosandGitHub 9a1d707f54 Revamp the UI on Login / Register page (#2919) 2025-02-07 09:43:19 +02:00
Tiago VasconcelosandGitHub 736699af94 . (#2925) 2025-02-06 15:10:16 +02:00
Tiago VasconcelosandGitHub 432b3a0fe0 Reset wallet keys (#2929) 2025-02-06 15:07:36 +02:00
Tiago VasconcelosandGitHub 34a959f0bc Add a payments page for admin (#2910) 2025-02-06 14:48:54 +02:00
Vlad Stan c1d26bb274 fix: email check 2025-02-06 11:57:01 +02:00
Vlad Stan b24a65cf2a doc: move security config at the top 2025-02-04 15:32:37 +02:00
ArcandGitHub 960c58db87 Feat: Adds wallet icon/color select (#2917) 2025-02-04 15:03:35 +02:00
ArcandGitHub dd9b217fdf Fix index.html error (#2923)
* Fix index.html error

* first install
2025-02-04 11:53:08 +00:00
Vlad StanandGitHub 7af9da4a58 fix: convert BigDecimal object to int (postgres only) (#2918) 2025-01-30 10:09:13 +02:00
ArcandGitHub c23eae9e08 Some menu naming changes (#2911) 2025-01-28 14:55:41 +02:00
ArcandGitHub 66b2ca2ac4 fixes wallet ws (#2912) 2025-01-28 14:14:12 +02:00
Tiago VasconcelosandGitHub 25e5423511 Add bar spinner (#2915) 2025-01-28 14:00:11 +02:00
Tiago VasconcelosandGitHub 4c7567a552 fix: router force update (#2914) 2025-01-28 13:44:37 +02:00
blackcoffeexbtandGitHub 73e716085b Translate expiry to expirySeconds for Phoenixd (#2901) 2025-01-28 11:54:33 +02:00
ArcandGitHub cc33a49b12 feature: Default theme for new users (#2898) 2025-01-28 11:29:38 +02:00
ArcandGitHub eeebaffbed feature: admin toggle to disable outgoing payments (#2909) 2025-01-28 10:27:24 +02:00
8633891485 fix: Add Breez SDK trampoline payments support (#2905)
* fix: add support for trampoline payments in Breez integration

- Introduced `BREEZ_USE_TRAMPOLINE` configuration in `.env.example`.
- Updated `BreezSdkFundingSource` to include `breez_use_trampoline` setting.
- Modified payment request in `breez.py` to utilize the trampoline setting when sending payments.

* Update .env.example

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

* Update lnbits/settings.py

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

* refactor: streamline payment request formatting in Breez integration

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2025-01-25 22:21:55 +01:00
Pavol RusnakandGitHub cb5cc1e5e8 make things work with Python 3.13 + fix Nix (#2908)
* update pyproject to make things work with python 3.13

* fix nix build
2025-01-23 23:11:45 +02:00
Vlad StanandGitHub f845bfe651 feat: do not allow user_id_only login for admins (#2904) 2025-01-23 15:01:54 +02:00
Vlad StanandGitHub b6bdf50ed7 [feat] Watchdog and notifications (#2895) 2025-01-23 13:23:09 +02:00
Vlad StanandGitHub 56a4b702f3 fix: multiple ws connections (#2907) 2025-01-23 13:05:35 +02:00
ArcandGitHub 273ab9781f fix: fiat tracking / updating wallet without reloads (#2891) 2025-01-22 10:27:05 +02:00
ArcandGitHub 8a759c8fc1 feature: Optional bg image and neon borders (#2897)
* init

* working
2025-01-18 19:27:50 +00:00
Tiago VasconcelosandGitHub c7ebe09463 Fix payment table refresh (#2894) 2025-01-17 09:49:14 +02:00
ArcandGitHub 5d4d22dcdd fix: for g.user error on public pages (#2892)
* fix for g.user error

* dont even mount
2025-01-16 16:18:15 +00:00
Vlad StanandGitHub b164317121 [feat] access control lists (with access tokens) (#2864) 2025-01-16 17:25:27 +02:00
Tiago VasconcelosandGitHub f415a92914 feat: harmonize the UI for payment dialog (#2882) 2025-01-16 14:31:33 +02:00
Vlad Stan 16c78f1901 fix: websocket prefix 2025-01-16 14:19:26 +02:00
ArcandGitHub 6a08d20fe8 v1 feat: Vue Routes (#2872) 2025-01-16 13:23:34 +02:00
dni ⚡andGitHub 5abfbdd07a fix: lnbits/upgrades where not ignore (#2889)
after updating extension i saw a dirty repo with the upgrades, this one
ignores them for sure
2025-01-15 18:32:53 +01:00
Riccardo BalboandGitHub bba7614012 fix: Add timeout to wait_for_connection (#2885) 2025-01-14 11:31:12 +02:00
dni ⚡andGitHub 2ee7180aa0 fix: cln nodemanager errors on failed transactions (#2865) 2025-01-13 17:58:37 +02:00
Pavol RusnakandGitHub 982732cbd4 i18n ai (#2881) 2025-01-11 12:53:52 +01:00
dfdb34852c chore: update i18n labels (#2871)
---------

Co-authored-by: dni  <office@dnilabs.com>
2025-01-10 12:15:51 +01:00
8fa5c81088 chore: update to breez-sdk 0.6.6 (#2878)
* chore: update to breez-sdk 0.6.6

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2025-01-10 11:56:10 +01:00
91748fc891 fix: restore user type badge (#2861)
---------

Co-authored-by: dni  <office@dnilabs.com>
2025-01-10 11:55:05 +01:00
Riccardo BalboandGitHub 0574837950 fix: NWC mark and remove subs before sending close request (#2870)
* mark and remove subscription even if close event fails to send

* catch CLOSE subscription exceptions

* format
2025-01-10 12:41:16 +02:00
dni ⚡andGitHub 7e27b2c6f0 fix: lndrest nodemanager get_channel (flaky ci decoy) (#2877) 2025-01-09 17:04:32 +01:00
6f3d0edfbd fix: ci with docker image built (downgrade poetry) (#2876)
* fix: ci with docker image built (downgrade poetry)

* use latest 1.8.x poetry

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2025-01-09 11:21:41 +02:00
dni ⚡andGitHub 3900d2871d feat: nodemanager, view and edit channels fees (#2818) 2024-12-20 09:48:09 +01:00
ArcandGitHub 0c5b909c7a Added settings links and link for theme (#2860) 2024-12-20 10:07:50 +02:00
ArcandGitHub 78437eaf94 feat: moved search inside the transaction table (#2859) 2024-12-20 07:44:07 +01:00
ArcandGitHub 8d1542d982 Added top cards (#2858)
* refactor old atempt

* Added watcher for smaller screens to stop bugs switching

* syntax error

* format
2024-12-19 16:16:09 +00:00
ArcandGitHub 146a74761a feat: standardises top spacing of page layout (#2848) 2024-12-19 15:14:30 +01:00
dni ⚡andGitHub 31664a4ba0 chore: update to v1.0.0-rc7 (#2856) 2024-12-19 12:00:40 +01:00
dni ⚡andGitHub f985a1eb08 test: use regtest main again (#2855) 2024-12-19 11:32:00 +01:00
4e5b001901 docs: updated install guide (#2846)
* Updated install guide
* Needed for secp256k1
* added lnbits-cli
* heading missing
* prettier

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-12-19 10:23:49 +01:00
dni ⚡andGitHub 3a2cf12052 fix: get_payments where clause does query wrong payments including tests (#2844)
* fix: get_payments where clause does query wrong payments (#2841)
2024-12-19 10:22:37 +01:00
Vlad StanandGitHub f17ff3d12b fix: set default values (#2854) 2024-12-19 10:08:34 +02:00
Vlad StanandGitHub 32c042741d fix: backwards compatible (#2853) 2024-12-19 09:50:52 +02:00
dni ⚡andGitHub d234ab12d8 ci: docker image and test with python3.12 (#2842) 2024-12-18 10:37:29 +02:00
37187bfc2c feat: add negative topups (#2835)
* feat: add negative topups
* remove topup dialog

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-12-17 21:06:58 +01:00
dni ⚡andGitHub 368da935db chore: update to corelightning 24.11 (#2836) 2024-12-17 10:02:34 +02:00
Tiago VasconcelosandGitHub 81aef06f75 make dialogs position at top (#2837) 2024-12-17 10:01:57 +02:00
Vlad StanandGitHub 27241e01f3 [feat] introduce max_lnbits_version for extensions (#2834)
* feat: add `max_lnbits_version`

* fix: min/max lables

* chore: `make bundle`

* refactor: extract `is_version_compatible_with_lnbits`

* refactor: rename method
2024-12-16 17:41:36 +02:00
Tiago VasconcelosandGitHub 3ff85363f2 hotfix the hotfix (#2832) 2024-12-16 16:38:22 +01:00
Vlad StanandGitHub b81b30c896 [feat] update multiple extensions from the UI (#2833)
* feat: add `Update` button
* feat: select all updatable extensions
* feat: dialog improvements
* fix: bad logging and permissions
2024-12-16 13:54:49 +01:00
jackstar12andGitHub 25d59e4142 feat: emulate behaviour of paid_invoices_stream for wallets that dont support it (#2071) 2024-12-16 13:37:28 +02:00
dni ⚡andGitHub 5f8ccee5b8 feat: websocket for payments events, remove /payments/sse and longpolling endpoint (#2704) 2024-12-16 11:10:25 +02:00
Tiago VasconcelosandGitHub 740180d332 fix: don't show when custom badge is not set (#2827) 2024-12-16 10:50:19 +02:00
dni ⚡andGitHub b66a8b3de9 fix: make startup extension check sync (#2819) 2024-12-16 10:45:39 +02:00
dni ⚡andGitHub b3351f2b17 fix: remove queue limit for invoice listners (#2829) 2024-12-13 17:12:35 +02:00
Vlad StanandGitHub 36dbdaa159 feat: return the price with the fiat rate (#2823)
* feat: return the price with the fiat rate
* fix: not supported currencies
2024-12-13 13:42:39 +01:00
Vlad StanandGitHub 524a4c9213 [feat] custom exchange providers (#2797) 2024-12-13 14:01:54 +02:00
dni ⚡andGitHub 200b9b127c test: fix jmeter for ci (#2825)
* print lnbits log
* fix java version for jmeter tests
2024-12-13 09:36:12 +01:00
dni ⚡andGitHub 646c341840 fix const req (#2824) 2024-12-13 08:25:44 +01:00
Vlad StanandGitHub 826f8ad2ac fix: ext upgrade from lnbits-cli (#2822) 2024-12-12 15:18:03 +02:00
dni ⚡andGitHub 1b9bb32ca0 test: fix lndrest on regtest (#2811)
* fix: lndrest on regtest
* fix some race conditions on lndrest
2024-12-12 07:54:43 +01:00
Tiago VasconcelosandGitHub 61c71660b8 fix: error on custom badge (#2814) 2024-12-11 12:50:35 +01:00
Vlad StanandGitHub 80ac158ca0 fix: extension init (#2815) 2024-12-11 13:49:12 +02:00
dni ⚡andGitHub 291c69e470 chore: update to latest pytest (#2800) 2024-12-11 11:39:28 +02:00
dni ⚡andGitHub 8ff4962e86 test: update ci from ubuntu-latest to ubuntu-24.04 (#2809) 2024-12-11 11:38:06 +02:00
dni ⚡andGitHub 57e9d2271d test: update python to 3.10 and 3.11 inside CI (#2803)
* test: update python to 3.10 and 3.11 inside CI

* fixup!
2024-12-11 10:39:25 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c18816bf1b chore(deps): bump nanoid from 3.3.7 to 3.3.8 (#2798)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-11 07:29:00 +01:00
dni ⚡andGitHub 8f290a647d chore: clean up javascript functions and var/let into const (#2791)
* chore: clean up javascript functions and var/let into const
2024-12-10 13:42:01 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
d1cc317044 chore(deps): bump @intlify/core-base and vue-i18n (#2790)
* chore(deps): bump @intlify/core-base and vue-i18n

Bumps [@intlify/core-base](https://github.com/intlify/vue-i18n/tree/HEAD/packages/core) to 10.0.5 and updates ancestor dependency [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n). These dependencies need to be updated together.


Updates `@intlify/core-base` from 10.0.3 to 10.0.5
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v10.0.5/packages/core)

Updates `vue-i18n` from 10.0.3 to 10.0.5
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v10.0.5/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: "@intlify/core-base"
  dependency-type: indirect
- dependency-name: vue-i18n
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* update vue-i18n

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2024-12-10 12:00:55 +01:00
dni ⚡andGitHub 5a1d660c7b fix: last_payment on userlist was broken (#2796)
* fix: last_payment on userlist was broken
2024-12-10 11:48:43 +01:00
7ee78248b7 feat: add failed payments toggle to wallet page (#2794)
* feat: add failed payments toggle to wallet page

---------

Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2024-12-10 11:47:09 +01:00
Pavol RusnakandGitHub 5104cbb285 chore: use HTTPStatus where possible (#2795) 2024-12-09 13:50:33 +01:00
Vlad StanandGitHub fa71219ce7 [fix] wallet search on postgres (#2780)
* fix: wallet search for postgres
* reformat: make it easier to read
2024-12-09 12:16:34 +01:00
dni ⚡andGitHub f5f43e7668 chore: use HTTPStatus for ip middleware (#2792) 2024-12-09 12:44:01 +02:00
dni ⚡andGitHub 4b63d0924e chore: remove ignores from crypto.py (#2787) 2024-12-02 11:47:45 +01:00
dni ⚡andGitHub dc22088dcd chore: add type to migration (#2788) 2024-12-02 11:46:41 +01:00
dni ⚡andGitHub aa63879ff5 chore: update to v1.0.0-rc6 (#2784) 2024-11-28 18:01:08 +01:00
Vlad StanandGitHub 0547abe54a [feat] install extensions from dir (#2781)
* feat: search ext dir and install
* fix: `upgrade_hash` logic
* chore: clean-up `upgrade_hash` logic
* fix: screen refresh
* fix: ignore non-ext dirs
* fix: ext migration
2024-11-28 11:51:29 +01:00
dni ⚡andGitHub f5ccf5c157 fix: stuck pay_invoice (#2783)
db connection wasnt passed to `get_user_active_extensions_id`. and in
some contexts nested db connection will stuck the server
2024-11-28 11:50:15 +01:00
e3d6b6befa fix: some ui bugs (#2782)
remove splitter constrain

Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2024-11-28 10:43:31 +01:00
Vlad StanandGitHub fa8d7c665b Feat: audit (#2779) 2024-11-27 13:06:35 +02:00
f97f27121a feat: lnd_rest allow self payments (#2760)
* Update lndrest.py

- added the ability to receive self payments on the node

[issue] You have to enable circular payment on lnd.conf. Note that lnd.conf doesn't allow allow_self_payment parameter, that is why is necessary to define that on transaction.

* feat: add LND rest `"allow_self_payment"` option

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-11-26 13:32:44 +01:00
Vlad StanandGitHub af568d0f31 feat: improve user admin (#2777) 2024-11-19 10:33:57 +02:00
Vlad StanandGitHub 8c5c455f1c fix: ext upgrade (#2776) 2024-11-13 13:00:05 +02:00
Vlad Stan bbae4a01c1 fix: remove release candidate suffix 2024-11-13 10:59:27 +02:00
Vlad StanandGitHub edfd297bf3 refactor: simplify condition (#2775) 2024-11-13 10:28:46 +02:00
Vlad StanandGitHub 09b1623bb0 feat: let user filter its own extensions (#2774) 2024-11-12 15:17:05 +02:00
Vlad StanandGitHub fa18170ed7 fix: extension name and icon (#2771) 2024-11-12 10:34:45 +02:00
Vlad StanandGitHub 3eece03715 fix: vue3 changes (#2770) 2024-11-12 10:01:33 +02:00
Vlad StanandGitHub dd972e2aaa [ui] update settings UI (#2767) 2024-11-08 10:15:56 +02:00
Vlad StanandGitHub ec9ad9f940 refactor: make settings key-value in DB (#2766) 2024-11-08 10:06:21 +02:00
Vlad StanandGitHub aced333c0b fix: check the settings for extension installed (#2764) 2024-11-06 12:07:12 +02:00
Vlad StanandGitHub ba5f79da2d refactor: extract models (#2759) 2024-11-05 13:26:12 +02:00
dni ⚡andGitHub acb1b1ed91 fix: add list handling (#2758) 2024-11-01 11:12:18 +02:00
Vlad StanandGitHub 2fa0a3c995 fix: v1 left over (#2757) 2024-10-31 15:49:55 +02:00
Vlad StanandGitHub 1dddc9e7f0 fix: handle list models (#2756) 2024-10-31 14:21:23 +02:00
Osvaldo RosalesandGitHub 581f98b3a3 Add NFC Payment Support and Display Receive Amount in Receive Dialog (#2747)
* feat: add readNfcTag to core wallet

* feat: added payments/ endpoint to pay invoice with lnurlw from nfc tag

* feat: add notifications to nfc read and payment process

* feat: display sat and fiat amount on receive invoice

* feat: add notifications for non-lnurl nfc tags

* removed unnecesary payment updates

* fix: case when lnurlw was already used. lnurl_req status error

* fix: lnurl response status error

* fix: abort nfc reading on receive dialog hid

* feat: dismiss tap suggestion when nfc tag read successfully

* update: NFC supported chip

* remove console.log

* add: function return type

* test: happy path for api_payment_pay_with_nfc

* feat: follow LUD-17, no support for lightning: url schema

* explicit lnurl withdraw for payment

* test: add parametrized tests for all cases of api_payment_pay_with_nfc endpoint

* fix: payment.amount in response comes already in milisats
2024-10-29 23:50:48 +01:00
ArcandGitHub 89a75ab641 Hide admin keys in curl example (#2753) 2024-10-29 23:49:46 +01:00
dni ⚡andGitHub f228198c20 fix: default timestamp on models (#2755)
closes #2754
2024-10-29 15:39:06 +01:00
2940cf97c5 feat: parse nested pydantic models fetchone and fetchall + add shortcuts for insert_query and update_query into Database (#2714)
* feat: add shortcuts for insert_query and update_query into `Database`
example: await db.insert("table_name", base_model)
* remove where from argument
* chore: code clean-up
* extension manager
* lnbits-qrcode  components
* parse date from dict
* refactor: make `settings` a fixture
* chore: remove verbose key names
* fix: time column
* fix: cast balance to `int`
* extension toggle vue3
* vue3 @input migration
* fix: payment extra and payment hash
* fix dynamic fields and ext db migration
* remove shadow on cards in dark theme
* screwed up and made more css pushes to this branch
* attempt to make chip component in settings dynamic fields
* dynamic chips
* qrscanner
* clean init admin settings
* make get_user better
* add dbversion model
* remove update_payment_status/extra/details
* traces for value and assertion errors
* refactor services
* add PaymentFiatAmount
* return Payment on api endpoints
* rename to get_user_from_account
* refactor: just refactor (#2740)
* rc5
* Fix db cache (#2741)
* [refactor] split services.py (#2742)
* refactor: spit `core.py` (#2743)
* refactor: make QR more customizable
* fix: print.html
* fix: qrcode options
* fix: white shadow on dark theme
* fix: datetime wasnt parsed in dict_to_model
* add timezone for conversion
* only parse timestamp for sqlite, postgres does it
* log internal payment success
* fix: export wallet to phone QR
* Adding a customisable border theme, like gradient (#2746)
* fixed mobile scan btn
* fix test websocket
* fix get_payments tests
* dict_to_model skip none values
* preimage none instead of defaulting to 0000...
* fixup test real invoice tests
* fixed pheonixd for wss
* fix nodemanager test settings
* fix lnbits funding
* only insert extension when they dont exist

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
Co-authored-by: Arc <ben@arc.wales>
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2024-10-29 09:58:22 +01:00
Vlad StanandGitHub ae4eda04ba test: more payment tests (#2738)
* test: pay_invoice
2024-10-17 10:27:36 +02:00
Vlad StanandGitHub 13f2dd732f fix: await retry (#2739) 2024-10-16 11:09:45 +02:00
Pavol RusnakandGitHub 80ec9e1307 update starlette and fastapi (#2737) 2024-10-16 07:00:12 +02:00
Vlad StanandGitHub 3b503eaa8a feat: add server status endpoin (#2736) 2024-10-15 14:53:49 +03:00
dni ⚡andGitHub ecc62b0011 feat: explicitly export for extensions from __init__.py (#2669)
* feat: explicitly export for extensions from `__init__.py`

makes it clear what extensions are expected to use and also makes future
changes to the structure of core safer because extension can just depend
on ```from lnbits import require_admin_key``` for example
2024-10-14 10:35:41 +03:00
b83c2e9368 docs: fix installation guide (#2734)
Co-authored-by: Gálli Zoltán <gallizoltan@gmail.com>
2024-10-10 21:22:15 +02:00
jackstar12andGitHub 512c85592f fix(boltz): sanitize invoice description (#2731)
Boltz rejects nbsp char (produced by JS Intl.NumberFormat api), so simply replace it with normal space
2024-10-09 19:32:56 +02:00
jackstar12andGitHub 76b01695a0 fix(boltz): check for direct onchain payments (#2732) 2024-10-09 18:52:20 +02:00
jackstar12andGitHub d7065392d1 chore: add .idea folder to gitignore (#2730) 2024-10-09 19:31:50 +03:00
Vlad StanandGitHub a58deff70c feat: block pay invoice (#2727) 2024-10-01 17:17:42 +03:00
dni ⚡andGitHub e85a78854e feat: make LNBITS_ADMIN_UI the default (#2726)
* feat: make LNBITS_ADMIN_UI the default
* fix create fake admin
2024-10-01 11:36:22 +02:00
dni ⚡andGitHub a28e334c5f test: fix test warning (#2725) 2024-10-01 11:07:13 +02:00
a4c000d7dc feat: add password reset for usermanager (#2688)
* feat: add password reset for usermanager
- add a reset_key to account table
- add ?reset_key= GET arguments to index.html and show reset form if provided
- superuser can generate and copy reset url with key to share
future ideas:
- could add send forgot password email if user fill out email address
* feat: simplify reset key
* test: use reset key
* test: add more tests
* test: reset passwords do not match
* test: `reset_password_auth_threshold_expired`

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-10-01 10:59:57 +02:00
dni ⚡andGitHub 3a64cf5245 feat: add currency options to LNbits.api (#2713)
* feat: add currency options to LNbits.api
2024-09-30 14:01:30 +02:00
0b8da2b524 [feat] Nostr Login (#2703)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-09-30 13:53:38 +02:00
dni ⚡andGitHub f062b3d5e5 ci: do not create gh release on rc's (#2716)
* ci: do not create gh release on rc's
2024-09-30 08:51:19 +02:00
dni ⚡andGitHub b9e9da5da7 fix: fundingsources template id was wrong (#2723) 2024-09-29 22:59:13 +02:00
dni ⚡andGitHub 953a466119 fix: more vue3 q-tables and v-model:value (#2717) 2024-09-27 15:18:56 +02:00
dni ⚡andGitHub 7ad360d176 fix: vue3 i18n changed it replacements markers (#2722)
* fix: vue3 i18n changed it replacements markers
* chore: bundle
2024-09-27 14:59:06 +02:00
dni ⚡andGitHub 898f264951 refactor: use <template> for components (#2715)
* refactor: use <template> for components
* fix paymentchart
2024-09-27 12:09:47 +02:00
dni ⚡andGitHub 75bcbdc18d chore: remove used crud for webpush settings (#2719) 2024-09-27 09:35:33 +03:00
dni ⚡andGitHub 3ac6bc3c3b fix: vue3 language switcher (#2712) 2024-09-25 12:09:00 +02:00
Vlad StanandGitHub 04882e05a3 chore: update some log messages (#2710) 2024-09-25 11:22:00 +03:00
dni ⚡andGitHub cf2aca3327 fix: forgotten Vue inits (#2708) 2024-09-25 08:01:11 +03:00
dni ⚡andGitHub 1d0547ea97 fix: use Quasar.notify (#2707)
* fix: use Quasar.Notify.create
instead of this.$q, had issues when pasting invoice, using the `Quasar`
is more consistent
2024-09-24 16:18:56 +02:00
dni ⚡andGitHub 99c5f00c91 fix: extensions tab for vue3 (#2706) 2024-09-24 13:39:31 +03:00
dni ⚡andGitHub 723d8ba98f chore: update to v1.0.0-rc2 (#2705) 2024-09-24 11:48:28 +02:00
daee2b3418 Check for theme params on the URL (#2678)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-09-24 11:44:07 +02:00
dni ⚡andGitHub 9d7e54f6b2 refactor: use CreatePayment model instead of a lot of kwargs (#2667)
- refactoring create_payment a bit to use a model instead of 10 kwargs
2024-09-24 11:13:30 +02:00
053ea20508 feat: update to Vue3 (#2677)
* update packages for vue3
* fix make bundle and make checkbundle to include bundle-components
* add lnbits/static/bundle-components.js

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-09-24 11:06:27 +02:00
dni ⚡andGitHub 04aefc8077 refactor: remove get_key_type decorator (#2676)
* refactor: remove `get_key_type` decorator
breaking change for 1.0.0
2024-09-24 10:56:34 +02:00
21d87adc52 mega chore: update sqlalchemy (#2611)
* update sqlalchemy to 1.4
* async postgres

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-09-24 10:56:03 +02:00
dni ⚡andGitHub c637e8d31e fix: internal payment can still be pending (#2686)
bug introduced last commit
2024-09-16 20:43:17 +02:00
dni ⚡andGitHub d26e50ec9a refactor: rename is_uncheckable (#2670)
it actually means is_internal and internal payments cant fail so we return success status
2024-09-16 17:34:30 +02:00
ArcandGitHub d229b7a765 fix: bash failed using after install (#2685) 2024-09-14 11:08:25 +02:00
ceb43f384e feat: install lnbits.sh bash script (#2684)
Co-authored-by: arcbtc <ben@arc.wales>
2024-09-12 08:02:47 +02:00
dni ⚡andGitHub 22e6326bce fix: gitignore extensions (#2682) 2024-09-11 19:34:35 +02:00
Vlad StanandGitHub 5f4f1288d7 Fix overlapping redirect paths (#2671) 2024-09-11 12:41:37 +03:00
blackcoffeexbtandGitHub 7a5e7fbd8c feat: UI / UX improvements to Users balance / tx chart (#2672)
* Updates to user manager chart to add axis label, bubble radius depending on balance and bubble labels with wallet info

* Fixed bg colour missing on toggle admin on user manager table
2024-09-11 09:40:41 +02:00
dni ⚡andGitHub 6c8d56e40c chore: update to 1.0.0-rc1 (#2675)
* chore: update to 1.0.0-rc1
2024-09-05 12:28:40 +02:00
Tiago VasconcelosandGitHub 8aa1716e32 Add detailed CSV export option (#2618) 2024-09-04 13:04:55 +03:00
fbd22c1a22 fix: QR request case (#2668)
* fix the hotfix to fix a fuckup
* fixup!

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-09-03 16:05:55 +02:00
dni ⚡andGitHub e8a6870d7a fix: update lnurl for backwards compatibility (#2666) 2024-09-03 12:03:26 +02:00
Tiago VasconcelosandGitHub 937936ff33 fix fuck up on refactor (#2665)
Bug introduced in #2656
2024-09-03 12:03:09 +02:00
Pavol RusnakandGitHub ab3eb967b4 chore: update pyproject dependencies (#2619) 2024-09-03 11:09:36 +02:00
dni ⚡andGitHub 6341e1edaf feat: add baseurl to admin ui settings (#2644)
* feat: add baseurl to admin ui settings

server tab with a hint that it is currently not used.

i ran into an issue developing an extension where i needed to know the
url inside a task, where i cannot pass the `Request` object.
so i depend on `settings.lnbits_baseurl` there
2024-09-03 07:08:56 +02:00
69d518bac0 Uncomment LNBITS_ALLOWED_FUNDING_SOURCES (#2663)
* Uncomment LNBITS_ALLOWED_FUNDING_SOURCES

I don't see why this exists

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-08-30 18:51:28 +02:00
dni ⚡andGitHub 9db2429a45 refactor: move migrate_databases to core helpers (#2636)
commands.py should be used for cli commands
2024-08-30 18:48:50 +02:00
dd90dec768 feat: frontend gradient option (#2561)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-08-30 18:16:24 +02:00
dni ⚡andGitHub eb37a064ad feat: vue components lnbits-dynamic-fields validation (#2645)
* feat: vue components lnbits-dynamic-fields validation

- add validation to fields if require = true
- add type hidden field (can be useful for create/update dialog with
passing item_id into update hidden field)
2024-08-30 18:06:55 +02:00
Tiago VasconcelosandGitHub 28df100d9a Fix lnurl fallback (#2656)
* Fix lnurl fallback

Wrong string case search

Closes #1599

* optimize the code

* one more clean up
2024-08-30 12:48:24 +01:00
6e6b387b7d feat: log with console.error on notifyApiError (#2646)
* feat: log with `console.error` on `notifyApiError`

usually why have code like that
```
    getAudit() {
      LNbits.api
        .request('GET', '/admin/api/v1/audit',
this.g.user.wallets[0].adminkey)
        .then(response => {
          this.auditData = response.data
          xonst myerror = isSwallowed()
        })
        .catch(function (error) {
          LNbits.utils.notifyApiError(error)
        })
    },

```
which if you make a mistake hides your error. logging console.error here
again makes it easier to see what is happening while developing and
doesnt hurt for the user aswell

* chore: bundle

* fixup!

---------

Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2024-08-30 12:23:11 +01:00
Tiago VasconcelosandGitHub 209cf7fbe0 fix chips on server page (#2654)
Fix chips not having the remove functionality

Closes #2471
2024-08-30 12:19:22 +01:00
Pavol RusnakandGitHub 596167f443 chore: update install instructions for developers (#2652) 2024-08-30 12:18:42 +01:00
Gonçalo ValérioandGitHub 4732c4b296 add proper validation for the unit field when creating an invoice (#2647) 2024-08-30 12:17:52 +01:00
Tiago VasconcelosandGitHub 405a2f0776 fix decode info (#2655)
Closes #1837
2024-08-30 12:15:29 +01:00
dni ⚡andGitHub 304ad3035b feat: add generic lnurl error response handler (#2638)
* feat: add generic lnurl error response handler

this is used multiple times in extensions to safeguard `views_lnurl.py`
endpoint to not return a wrong lnurl error response.

you use it by just setting following on your lnurl router/view

```
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
withdraw_ext_lnurl.route_class = LNURLErrorResponseHandler
```
2024-08-30 13:12:55 +02:00
Pavol RusnakandGitHub 8cffda5a55 chore: update package.json (#2635)
* chore: update package.json

* chore: make bundle
2024-08-29 22:51:41 +02:00
Tiago VasconcelosandGitHub cbe858b385 show wallet names in dropdown (#2653)
Extensions will need it also

Fix #2517
2024-08-29 21:43:00 +01:00
dni ⚡andGitHub 65ecca2507 feat: add rate endpoint per currency (#2641)
this is already used and implemented by tpos, lnurlp and i probably need
it for satspay aswell
2024-08-20 10:52:39 +01:00
1900cf9aa4 feat: add boltz client fundingsource (#2358)
* feat: add boltz client standalone fundingsource
WIP.
https://docs.boltz.exchange/v/boltz-client

this fundingsource utilizing the boltz client standalone function: https://github.com/BoltzExchange/boltz-client/pull/123
this makes him act like a lightning node while submarine swapping everything on liquid network. like aqua does in its wallet.

* feat: paid_invoices_stream

* feat: proper invoice and payment status check

* feat: authenticate over insecure channel aswell

* chore: lint

* docs: add more setup instructions

* chore: add `boltz_client_cert` in frontend

* feat: populate fee_msat in get_payment_status and get_invoice_status

* fixup!

* chore: bundle

* added boltz logo

* add BoltzWallet to __all__

* chore: bump grpcio and protobuf deps and add grpcio-tools as dev dependency

* chore: update protos

* feat: pass description when creating swap

* fixup!

* chore: bundle

---------

Co-authored-by: jackstar12 <jkranawetter05@gmail.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-20 10:38:30 +01:00
Tiago VasconcelosandGitHub 296b1dfa9a add copy buttons (#2643) 2024-08-19 15:23:03 +02:00
dni ⚡andGitHub b14e0e4cc6 feat: add label to lnbits-dynamic-fields (#2637)
* feat: add label to `lnbits-dynamic-fields`

* chore: bundle
2024-08-14 16:52:19 +02:00
Ross SavageandGitHub fb585402dd update breez-sdk to 0.5.2 (#2639) 2024-08-14 15:42:12 +01:00
dni ⚡andGitHub 63f246908e fix: add back lnurl wallet (#2625)
* fix: add back lnurl wallet
* add comment from withdraw ext
* fixup, when you use unique links
2024-08-08 11:09:01 +02:00
dni ⚡andGitHub 8ac827f5a4 fix: export nwc wallet (#2632)
quick fix, #2630
2024-08-08 07:34:31 +02:00
dni ⚡andGitHub 74d4ddd312 feat: use __all__ to export deps from __init__.py (#2630)
* feat: export wallets
* remove linting exception
2024-08-08 07:29:21 +02:00
dni ⚡andGitHub 40ffa7dea0 test: refactor to not use paid_invoices stream for real invoice tests (#2628) 2024-08-07 16:19:53 +02:00
dni ⚡andGitHub ddb8fcb986 feat: add typing for tasks (#2629)
* feat: add typing for tasks

* fixup!
2024-08-07 09:57:15 +02:00
27b9e8254c feat: NWC Funding source #2579 (#2631)
* feat: nwc funding source

* implement paid_invoices_stream, fix for unsettled invoices where settled_at is present but None

* cancel pending_payments_lookup_task on cleanup

* Rename subscription_timeout_task to timeout_task

* ensure preimage is not None

* Improve readability, return failed status on expiration in get_payment_status, ensure result_type is checked after error (some implementations might not set a result_type on error)

* fetch account info when possible

* workaround possible race condition on some nwc service providers, improve performance of fallback by using payment_hash from bolt11 invoice

* fundle

* make format

* fix formatting

* fix C901 `_on_message` is too complex (21 > 16)

* format

* fix lint

* format

* fix tests/wallets/test_nwc_wallets.py:80:11: C901 `run` is too complex (17 > 16)

* fix padding

* fix documentation for _verify_event method

* refactoring and fixes

* Split NWCWallet - NWCConnection

* refactor class methods into helpers

* update bundle

* format

* catch NWCError failure codes

* format and fix

* chore: bundle

* add example

* typos

---------

Co-authored-by: Riccardo Balbo <riccardo0blb@gmail.com>
Co-authored-by: benarc <ben@arc.wales>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-07 09:56:53 +02:00
daa4b92331 fix: db helpers to be used with timestamps (#2627)
* fix: db helpers to be used with timestamps

those helpers are used in boltz extension and they did not take dates
into consideration yet

* vlad picks
* refactor get_placeholder

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-06 12:43:44 +02:00
0015314e11 feat: add Breez SDK wallet (#1897)
* add Breez SDK wallet
* use more description status classes
* fix: add try-except

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-08-06 10:06:21 +02:00
blackcoffeexbtandGitHub 235f8a6c19 Invoice creation UI: Replace input mask with pattern and inputmode (#2623)
* Invoice creation UI. Replace input mask with pattern and inputmode
2024-08-02 09:26:10 +03:00
Pavol RusnakandGitHub bab399f825 fix: run cachix nix action for all branches (#2624) 2024-08-01 21:54:15 +02:00
dni ⚡andGitHub 646a604221 feat: add release flow for release candidates (#2620)
- pushes docker tag
- pushes to pypi for extensions to update
- generates a prerelease on github
2024-08-01 13:05:02 +02:00
7d8fad267a fix: set a maximium sleep time when retrying to connect to the funding source (#2622)
---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-01 13:02:55 +02:00
dni ⚡andGitHub ce0aff206d fix: skip pending payment check on void (#2610)
skips payment check if voidwallet is active
2024-07-31 15:06:31 +03:00
80e7b9639d feat: filter response fields for /api/v1/payments/decode (#2612)
* feat: filter response fields

* chore: `make format`

* chore: comment

* Update lnbits/helpers.py

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

* Update lnbits/helpers.py

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

* chore: code format

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-07-31 14:36:42 +03:00
dni ⚡andGitHub 94caa2e1ba fix: still flaky regtest (#2617)
increase wait
2024-07-31 12:05:07 +02:00
dni ⚡andGitHub b0a66e8cf5 fix: random exceptions inside logs in middleware (#2608)
closes #2599

special thanks to bitkarrot for figuring that out, it was actually
initialized twice, the decorator alone is enough!

also was issue for first_install middleware
2024-07-31 12:00:40 +02:00
dni ⚡andGitHub ffba71c0ce test: fix flaky regtest (#2616)
* add log for invoice success
* add internal flag
* sleeping inside the tasks to not block
* sleep was wrong decrease wait time
2024-07-31 11:41:19 +02:00
b41705167f feat: extra log for tests phases (#2604)
* fix: set `corelightning_rest_cert`
* feat: extra log for tests phases

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-07-31 11:40:25 +02:00
blackcoffeexbtandGitHub 0387db3b55 Update installation.md (#2615)
mkdir data no longer needed for standard installation
2024-07-31 10:40:17 +03:00
Tiago VasconcelosandGitHub 38ef1b0061 feat: add a copy wallet button (#2613)
* feat: add a copy wallet button
* fix: make button consistent
* feat: hide API keys
Closes #2462
* fix: consistency in icons
* fix: missing end tag
2024-07-31 08:51:17 +02:00
Pavol RusnakandGitHub 19c231a2f4 chore: update pyln-client to 24.5 (#2614) 2024-07-30 18:04:28 +02:00
BitkarrotandGitHub 026c9b5155 Fix for LNURLp with ZBD wallet (#2609) 2024-07-30 18:04:07 +02:00
Pavol RusnakandGitHub c834929f8b add bitpay and yadio fiat rate providers + increase precision of blockchain.info fiat rate provider (#2605)
* feat: increase precision of blockchain.info fiat rate provider
* feat: add bitpay and yadio fiat rate providers
2024-07-30 13:44:08 +02:00
dni ⚡andGitHub 97de4eda18 feat: add exclude_to list for btcprice providers (#2602) 2024-07-26 11:31:24 +02:00
dni ⚡andGitHub a844711530 bug: removing admin user was not persistent (#2603)
thanks for reporting, that was a serious bug. the indentation was wrong
2024-07-26 11:15:34 +02:00
dni ⚡andGitHub e2522b358a chore: bump version to 0.12.11 (#2601)
this is needed for testing the new lndhub version
2024-07-25 10:19:18 +02:00
dni ⚡andGitHub 8f761dfd0f refactor: add status column to apipayments (#2537)
* refactor: add status column to apipayments

keep track of the payment status with an enum and persist it as string
to db. `pending`, `success`, `failed`.

- database migration
- remove deleting of payments, failed payments stay
2024-07-24 16:47:26 +03:00
Pavol RusnakandGitHub b14d36a0aa chore(deps): replace python-jose with pyjwt (#2591)
python-jose had no release since 3 years
2024-07-24 10:42:47 +02:00
dni ⚡andGitHub dbb689c5c5 chore: update version to 0.12.10 (#2597) 2024-07-23 14:06:55 +02:00
dni ⚡andGitHub 2167aa398f fix: annotations for models.py (#2595) 2024-07-23 14:03:27 +02:00
dni ⚡andGitHub eb8d2f312f fix: install extensions async (#2596)
so it does not block webserver start on saas instances and comes up
faster if extensions are reinstalled
2024-07-23 14:01:34 +02:00
jackstar12andGitHub f9133760fc fix: proper status check in invoice paid callback (#2592)
status fields like preimage and fee_msat are never updated otherwise
2024-07-22 16:59:26 +02:00
7298c4664b feat: Blink funding source (#2477)
* feat: Blink funding source

* chore: make bundle

* Blink review 01 (#2575)

* refactor: mark `graphql_query` as private (`_` prefix)

* feat: set default value for `blink_api_endpoint`

* fix: raise if HTTP call failed

* refactor: move private method to the bottom

* refactor: make `wallet_id` a property

* fix: key mapping for attribute

* chore: fix `mypy`

* chore: fix `make check`

* refactor: extract query strings

* refactor: extract `BlinkGrafqlQueries` class

* chore: code clean-up

* chore: add `try-catch`

* refactor: extract `tx_query`

* chore: format grapfhql queries

* fix: set funding source class

* chore: `make format`

* fix: test by following the other patterns

* Update docs/guide/wallets.md

Co-authored-by: openoms <43343391+openoms@users.noreply.github.com>

* feat: add websocket connection to blink (#2577)

* feat: add websocket connection to blink

* feat: close websocket on shutdown

* feat: add `blink_ws_endpoint` to the UI

* fix: use `SEND` tx for `settlementFee`

* refactor: remove `else` when `if` has `return`

* fix: remove test env file

---------

Co-authored-by: bitkarrot <73979971+bitkarrot@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: openoms <43343391+openoms@users.noreply.github.com>
2024-07-19 21:32:02 +01:00
Pavol RusnakandGitHub eda7e35c61 chore: update python deps (#2586) 2024-07-16 07:59:04 +02:00
7d1e22c7de fix: always create default wallet for user (#2580)
* fix: always create default wallet for user

* no assert in api

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-07-15 13:34:26 +03:00
michael1011andGitHub 1bee84d419 chore: optimize Dockerfile (#2576)
By doing a multi-stage Docker build, the size
of the final image is reduced by ~45%.
2024-07-10 14:22:44 +02:00
a00292544f fix: lib secp256k fails building (#2572)
Co-authored-by: michael1011 <me@michael1011.at>
2024-07-09 15:51:40 +02:00
ArcandGitHub fe14c2cd83 Fixed ugly adv description card (#2570) 2024-07-09 14:03:11 +01:00
schneimiandGitHub 760f11f1ce Update wallet.js (#2569)
enable 'Read' button after 'Paste from clipboard' click
2024-07-09 13:57:03 +01:00
0e1090b717 docs: nginx websocket support for reverse proxy (#2564)
* docs: nginx websocket support for reverse proxy

websocket weren't working with that conf

* cleanup

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-07-09 13:56:31 +01:00
Vlad StanandGitHub b2564154cd [feat] add default_user_extensions setting (#2571)
* feat: add `lnbits_user_default_extensions` to `settings`

* refactor: extract `create_user_account` in services

* feat: auto enable user extensions
2024-07-09 13:55:05 +01:00
Vlad StanandGitHub fb17611207 [feat] add authenticated_user_id decorator (#2566)
* feat: add `optional_user_id` decorator
2024-07-04 17:24:20 +03:00
ArcandGitHub 7f628948c9 bug: carousel and adv desc fix (#2562)
* Carousel image fix
* pushed carousel nav to top as well so yt controls are accessible
* Added some extra breathing room for the adv description
2024-06-26 13:21:34 +02:00
dni ⚡andGitHub cbe0861439 feat: improve on extension page layout (#2558)
* feat: improve on extension page layout
give it more luft and simplify it a bit. also improved responsiveness
* show description now, ben
2024-06-26 13:07:13 +02:00
dni ⚡andGitHub e9d6160f4d chore: prepare for 0.12.9 (#2555) 2024-06-19 12:56:34 +02:00
Vlad StanandGitHub eacdd432b2 [feat] Extension details page (#2544)
* feat: add empty dialog

* feat: add `details_link` field for extension

* feat: show info icon if `details_link` present

* feat: add extension details endpoint

* feat: first details page

* feat: carousel working

* feat: full screen

* fix: layout

* fix: repo site

* fix: release icon

* fix: repo link

* feat: terms and conditions partial

* chore: fix typing

* fix: info icon layout

* chore: add try-catch

* feat: layout improvements

* feat: add video link

* fix: show terms and conditions

* chore: code format

* feat: add `details_link`

* fix: github release details

* feat: add close button

* chore: code clean-up

* chore: revert some changes

* feat: i18n

* chore: `make bundle`

* chore: make bundle

* feat: terms and conditions is a link now
2024-06-19 11:52:18 +01:00
Pavol RusnakandGitHub 76e8d72d0d LNBits -> LNbits typo (#2552)
* LNBits -> LNbits
2024-06-19 09:27:26 +03:00
dbacf7e8c1 "Failed to connect to https://ws:" crash (#2548)
* Update base.py

fixing bug in normalize_enpoints

* Update lnbits/wallets/base.py

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

---------

Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-06-17 17:36:15 +02:00
dni ⚡andGitHub b6d99b09cf fix: npm packages with high severity issue (#2551)
updating `braces` npm package
2024-06-17 17:17:53 +02:00
5c21e7f9ed Update installation.md (#2549)
Minor rewording

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-06-17 11:24:31 +02:00
14e9c7d9dc Fix typo of "LNbits" in list of funding sources. (#2546)
* Fix typo of "LNbits" in list of funding sources.


---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-06-17 09:08:22 +02:00
blackcoffeexbtandGitHub b3368d89f4 Nice formatting of funding source titles in Server admin (#2543)
* Display friendly funding source titles in funding sources list

* Sort funding options select alphabetically

* Run make bundle
2024-06-10 23:37:09 +02:00
83b89851a5 fix: phoenixd wallet description field supports lnurlp (#2514)
* Fix for phoenixd and lnurlp nostr usage

- Support description, restricted to 128 characters
- Support descriptionHash

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: Vic <vic@example.com>
2024-05-29 13:47:10 +03:00
dni ⚡andGitHub 2db5a83f4e chore: update LNbits to 0.12.8 (#2539) 2024-05-28 13:59:13 +02:00
d72cf40439 [feat] Pay to enable extension (#2516)
* feat: add payment tab

* feat: add buttons

* feat: persist `pay to enable` changes

* fix: do not disable extension on upgrade

* fix: show releases tab first

* feat: extract `enableExtension` logic

* refactor: rename routes

* feat: show dialog for paying extension

* feat: create invoice to enable

* refactor: extract enable/disable extension logic

* feat: add extra info to UserExtensions

* feat: check payment for extension enable

* fix: parsing

* feat: admins must not pay

* fix: code checks

* fix: test

* refactor: extract extension activate/deactivate to the `api` side

* feat: add `get_user_extensions `

* feat: return explicit `requiresPayment`

* feat: add `isPaymentRequired` to extension list

* fix: `paid_to_enable` status

* fix: ui layout

* feat: show QR Code

* feat: wait for invoice to be paid

* test: removed deprecated test and dead code

* feat: add re-check button

* refactor: rename paths for endpoints

* feat: i18n

* feat: add `{"success": True}`

* test: fix listener

* fix: rebase errors

* chore: update bundle

* fix: return error status code for the HTML error pages

* fix: active extension loading from file system

* chore: temp commit

* fix: premature optimisation

* chore: make check

* refactor: remove extracted logic

* chore: code format

* fix: enable by default after install

* fix: use `discard` instead of `remove` for `set`

* chore: code format

* fix: better error code

* fix: check for stop function before invoking

* feat: check if the wallet belongs to the admin user

* refactor: return 402 Requires Payment

* chore: more typing

* chore: temp checkout different branch for tests

* fix: too much typing

* fix: remove try-except

* fix: typo

* fix: manual format

* fix: merge issue

* remove this line

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-05-28 12:07:33 +01:00
Vlad StanandGitHub 7c68a02eee [feat] Check payment tag (#2522)
* feat: check if the payment is made for an extension that the user disabed
2024-05-24 17:24:59 +03:00
Vlad StanandGitHub 93965bc5b6 [test] webpush_api endpoints (#2534)
* test: webpush_api endpoints

* fix: SQL quote for `user`
2024-05-23 23:23:32 +02:00
Vlad StanandGitHub ae60b4517c [fix] SQL error for create webpush notification (#2533)
* fix: replace all SQL `user = ?` with `"user"" = ?`
* fix: surround with try-catch
* fix: bad double quote
2024-05-23 10:16:00 +02:00
dni ⚡andGitHub b15596d045 fix-fiat-balance (#2515) 2024-05-23 07:47:56 +02:00
Vlad StanandGitHub 07f0dc80f8 [fix] editable fields with default=None must be Optional (#2530)
* fix: optional fields

* fix: bad uppercase field
2024-05-22 14:03:52 +01:00
Pavol RusnakandGitHub 5f64c298c9 chore(i18n-ai-tool): use gpt-4o model + chore(i18n): update strings using the AI tool (#2511)
* chore(i18n-ai-tool): use gpt-4o model

* chore(i18n): update strings using the AI tool
2024-05-22 14:18:23 +03:00
dni ⚡andGitHub 5b056ce07e feat: update latest docker tag on release (#2528) 2024-05-22 13:47:41 +03:00
Vlad Stananddni ⚡ 44b458ebb8 [fix] check user extension access (#2519)
* feat: check user extension access
* fix: handle upgraded extensions
2024-05-22 11:10:35 +02:00
Vlad Stan d4da96597e chore: project version bump 2024-05-22 12:04:56 +03:00
Vlad StanandGitHub 6a0b645316 hotfix: check for lnbits_site_description setting (#2527)
* fix: make `lnbits_site_description` optional again
2024-05-22 10:53:34 +02:00
318 changed files with 79348 additions and 31049 deletions
+1
View File
@@ -3,6 +3,7 @@ data
docker
docs
tests
node_modules
lnbits/static/css/*
lnbits/static/bundle.js
+72 -17
View File
@@ -1,5 +1,17 @@
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
######################################
####### Auth Configurations ##########
######################################
# Secret Key: will default to the hash of the super user.
# !!!!! It is strongly recommended that you set your own strong random value !!!!
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
######################################
########### Admin Settings ###########
######################################
@@ -9,12 +21,12 @@
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this to make LNbits use this config file again.
LNBITS_ADMIN_UI=false
# Disable this and clear `settings` table from database to make LNbits use this config file again.
LNBITS_ADMIN_UI=true
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
@@ -28,7 +40,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -49,6 +61,20 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken
#CLNRest (using runes)
CLNREST_URL=https://127.0.0.1:3010
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
# CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
@@ -87,10 +113,27 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
BOLTZ_CLIENT_WALLET="lnbits"
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
# BlinkWallet
BLINK_API_ENDPOINT=https://api.blink.sv/graphql
BLINK_WS_ENDPOINT=wss://ws.blink.sv/graphql
BLINK_TOKEN=BLINK_TOKEN
# PhoenixdWallet
PHOENIXD_API_ENDPOINT=http://localhost:9740/
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
@@ -107,21 +150,29 @@ LNBITS_DENOMINATION=sats
ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw
# NWCWalllet
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
# LnTipsWallet
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips
######################################
####### Auth Configurations ##########
######################################
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
# BreezSdkWallet
BREEZ_API_KEY=KEY
BREEZ_GREENLIGHT_SEED=SEED
# A Greenlight invite code or Greenlight partner certificate/key can be used
BREEZ_GREENLIGHT_INVITE_CODE=CODE
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
# BREEZ_USE_TRAMPOLINE=true
# BreezLiquidSdkWallet
# get your own api key here https://breez.technology/request-api-key/#contact-us-form-sdk
# or keep the api key empty to use the LNbits key for referrals (API key is not a secret)
# BREEZ_LIQUID_API_KEY=""
BREEZ_LIQUID_SEED="MNEMONIC SEED PHRASE"
# BREEZ_LIQUID_FEE_OFFSET_SAT=50
# Google OAuth Config
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
@@ -138,14 +189,16 @@ GITHUB_CLIENT_SECRET=""
KEYCLOAK_CLIENT_ID=""
KEYCLOAK_CLIENT_SECRET=""
KEYCLOAK_DISCOVERY_URL=""
KEYCLOAK_CLIENT_CUSTOM_ORG=""
KEYCLOAK_CLIENT_CUSTOM_ICON=""
######################################
# uvicorn variable, uncomment to allow https behind a proxy
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
# FORWARDED_ALLOW_IPS="*"
FORWARDED_ALLOW_IPS="*"
# Server security, rate limiting ips, blocked ips, allowed ips
LNBITS_RATE_LIMIT_NO="200"
@@ -161,7 +214,9 @@ LNBITS_ADMIN_USERS=""
# SUPER_USER=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
# Extensions enabled by default when a user is created
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
# Start LNbits core only. The extensions are not loaded.
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
+5 -8
View File
@@ -4,10 +4,7 @@ inputs:
python-version:
description: "Python Version"
required: true
default: "3.9"
poetry-version:
description: "Poetry Version"
default: "1.7.0"
default: "3.10"
node-version:
description: "Node Version"
default: "20.x"
@@ -27,10 +24,8 @@ runs:
# cache poetry install via pip
cache: "pip"
- name: Set up Poetry ${{ inputs.poetry-version }}
- name: Set up Poetry
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ inputs.poetry-version }}
- name: Setup a local virtual environment (if no poetry.toml file)
shell: bash
@@ -46,7 +41,9 @@ runs:
- name: Install the project dependencies
shell: bash
run: poetry install
run: |
poetry env use python${{ inputs.python-version }}
poetry install --all-extras
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
LAUNCH_DIR="$PWD"
# Define persistent storage for extracted LNbits
PERSISTENT_DIR="$HOME/.local/share/lnbits"
# Remove existing LNbits directory before extraction
if [ -d "$PERSISTENT_DIR" ]; then
echo "Removing existing LNbits directory..."
rm -rf "$PERSISTENT_DIR"
fi
# Ensure the persistent directory exists
mkdir -p "$PERSISTENT_DIR"
# Extract LNbits from the AppImage if not already extracted
echo "Extracting LNbits to disk for better performance..."
cp -r "$APPDIR/usr/lnbits"/* "$PERSISTENT_DIR/"
chmod +x "$PERSISTENT_DIR/dist/lnbits"
# Check if the directory exists, and create it if it doesn't
if [ ! -d "$LAUNCH_DIR/lnbits/database" ]; then
mkdir -p "$LAUNCH_DIR/lnbits/database"
echo "Created database directory at $LAUNCH_DIR/lnbits/database"
fi
if [ ! -d "$LAUNCH_DIR/lnbits/extensions" ]; then
mkdir -p "$LAUNCH_DIR/lnbits/extensions"
echo "Created extensions directory at $LAUNCH_DIR/lnbits/extensions"
fi
cd "$PERSISTENT_DIR"
# Export the directory as an environment variable for the app
LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
export LNBITS_DATA_FOLDER
export LNBITS_EXTENSIONS_PATH
# Define the LNbits URL
URL="http://0.0.0.0:5000"
"./dist/lnbits" "$@" &
LNBITS_PID=$!
# Wait for LNbits to be ready before showing the popup
sleep 3
CLOSED=false
# Function to stop LNbits gracefully
kill_lnbits() {
LN_PIDS=$(lsof -t -i:5000 2>/dev/null) # Capture all PIDs
if [ -n "$LN_PIDS" ]; then
echo "Stopping LNbits (PIDs: $LN_PIDS)..."
kill -2 $LN_PIDS # Send SIGINT to all processes on port 5000
CLOSED=true
fi
}
# Show a GUI with a clickable link to open the browser
if command -v zenity >/dev/null 2>&1; then
while [ "$CLOSED" = false ]; do
zenity --info --title="LNbits" --width=400 --text="<b>LNbits is running.</b>\n\n<a href='$URL'>$URL</a>\n\nClick 'Close Server' to stop LNbits." --ok-label="Close Server"
kill_lnbits
sleep 1
done
elif command -v yad >/dev/null 2>&1; then
while [ "$CLOSED" = false ]; do
yad --title="LNbits" --width=400 --text="<b>LNbits is running.</b>\n\n<a href='$URL'>$URL</a>\n\nClick 'Close Server' to stop LNbits." --button="Close Server":0
kill_lnbits
sleep 1
done
else
echo "No GUI tool found. LNbits is running at $URL"
fi
# Ensure the script doesn't hang after closing
if ps -p $LNBITS_PID >/dev/null 2>&1; then
wait $LNBITS_PID 2>/dev/null || true
fi
@@ -0,0 +1,6 @@
[Desktop Entry]
Name=LNbits
Exec=lnbits
Icon=lnbits
Type=Application
Categories=X-Bitcoin;X-LightningNetwork;X-Finance;Network;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+6 -8
View File
@@ -16,7 +16,7 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9", "3.10"]
python-version: ["3.10", "3.11", "3.12"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
@@ -30,7 +30,7 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9", "3.10"]
python-version: ["3.10", "3.11", "3.12"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
@@ -44,7 +44,7 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9", "3.10"]
python-version: ["3.10", "3.11", "3.12"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
@@ -58,7 +58,7 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9", "3.10"]
python-version: ["3.10", "3.11", "3.12"]
uses: ./.github/workflows/migration.yml
with:
python-version: ${{ matrix.python-version }}
@@ -74,7 +74,7 @@ jobs:
uses: ./.github/workflows/regtest.yml
strategy:
matrix:
python-version: ["3.9"]
python-version: ["3.10"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
with:
custom-pytest: "poetry run pytest tests/regtest"
@@ -87,9 +87,7 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.5.1"]
python-version: ["3.10"]
uses: ./.github/workflows/jmeter.yml
with:
python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
analyze:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
+12 -1
View File
@@ -19,7 +19,7 @@ on:
jobs:
push_to_dockerhub:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -51,3 +51,14 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push boltz
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.boltz
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
+19 -8
View File
@@ -6,17 +6,12 @@ on:
python-version:
description: "Python Version"
required: true
default: "3.9"
type: string
poetry-version:
description: "Poetry Version"
required: true
default: "1.5.1"
default: "3.10"
type: string
jobs:
jmeter:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -31,9 +26,18 @@ jobs:
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
poetry run lnbits &
sleep 5
sleep 10
- name: setup java version
run: |
update-java-alternatives --list
sudo update-java-alternatives --set /usr/lib/jvm/temurin-8-jdk-amd64
java -version
- name: clone lnbits-extensions, install jmeter and run tests
env:
JAVA_HOME: /usr/lib/jvm/temurin-8-jdk-amd64
EXTENSIONS_MANIFEST_PATH: "/lnbits/lnbits-extensions/refs/heads/main/extensions.json"
run: |
git clone https://github.com/lnbits/lnbits-extensions
cd lnbits-extensions
@@ -43,6 +47,13 @@ jobs:
make start-mirror-server
make test
- name: print lnbits log
if: ${{ always() }}
run: |
# catch up time for lnbits
sleep 1
cat data/logs/debug.log
- name: upload jmeter test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
+4 -4
View File
@@ -8,7 +8,7 @@ jobs:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.9", "3.10"]
python-version: ["3.10", "3.11", "3.12"]
with:
make: checkblack
python-version: ${{ matrix.python-version }}
@@ -17,7 +17,7 @@ jobs:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.9", "3.10"]
python-version: ["3.10", "3.11", "3.12"]
with:
make: checkruff
python-version: ${{ matrix.python-version }}
@@ -26,7 +26,7 @@ jobs:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.9", "3.10"]
python-version: ["3.10", "3.11", "3.12"]
with:
make: mypy
python-version: ${{ matrix.python-version }}
@@ -35,7 +35,7 @@ jobs:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.9", "3.10"]
python-version: ["3.10", "3.11", "3.12"]
with:
make: pyright
python-version: ${{ matrix.python-version }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
name: ${{ inputs.make }} (${{ inputs.python-version }})
strategy:
matrix:
os-version: ["ubuntu-latest"]
os-version: ["ubuntu-24.04"]
node-version: ["18.x"]
runs-on: ${{ matrix.os-version }}
steps:
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
name: migration (${{ inputs.python-version }})
strategy:
matrix:
os-version: ["ubuntu-latest"]
os-version: ["ubuntu-24.04"]
runs-on: ${{ matrix.os-version }}
services:
postgres:
+5 -5
View File
@@ -25,14 +25,14 @@ on:
jobs:
nix:
runs-on: ubuntu-latest
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v24
- uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-23.11
- uses: cachix/cachix-action@v13
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
nix_path: nixpkgs=channel:nixos-24.05
- uses: cachix/cachix-action@v15
with:
name: lnbits
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
+72
View File
@@ -0,0 +1,72 @@
name: Build LNbits AppImage DMG
on:
release:
types: [published]
jobs:
build-linux-package:
runs-on: ubuntu-latest
steps:
# Step 1: Checkout the repository
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# Step 2: Install Dependencies
- name: Install Dependencies
run: |
curl -sSL https://install.python-poetry.org | python3 -
echo "$HOME/.local/bin" >> $GITHUB_PATH
sudo apt-get update
sudo apt-get install -y libfuse2
shell: bash
# Step 3: Clone LNbits Repository
- name: Clone LNbits
run: |
mv .github/packaging packaging
mkdir -p packaging/linux/AppDir/usr
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
shell: bash
# Step 4: Make the AppImage Asset
- name: Make Asset
run: |
cd packaging/linux/AppDir/usr/lnbits
poetry install
poetry run pip install pyinstaller
# Build the LNbits binary
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
cd ../../../../..
chmod +x packaging/linux/AppDir/AppRun
chmod +x packaging/linux/AppDir/lnbits.desktop
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
! -name 'dist' \
! -name 'lnbits' \
-exec rm -rf {} +
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
TAG_NAME=${{ github.event.release.tag_name }}
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
chmod +x "$APPIMAGE_NAME"
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
shell: bash
# Step 5: Upload Linux Release Asset
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4 -2
View File
@@ -8,10 +8,10 @@ on:
required: true
type: string
python-version:
default: "3.9"
default: "3.10"
type: string
os-version:
default: "ubuntu-latest"
default: "ubuntu-24.04"
type: string
backend-wallet-class:
required: true
@@ -63,6 +63,8 @@ jobs:
LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
ECLAIR_PASS: lnbits
PYTHONUNBUFFERED: 1
DEBUG: true
+32
View File
@@ -0,0 +1,32 @@
name: release-rc
on:
push:
tags:
- "*-rc[0-9]+"
permissions:
contents: write
jobs:
docker:
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
+15 -2
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -31,9 +31,22 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest:
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: latest
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
+2 -2
View File
@@ -8,10 +8,10 @@ on:
required: true
type: string
python-version:
default: "3.9"
default: "3.10"
type: string
os-version:
default: "ubuntu-latest"
default: "ubuntu-24.04"
type: string
db-url:
default: ""
+9 -2
View File
@@ -7,6 +7,7 @@ __pycache__
.mypy_cache
.vscode
*-lock.json
.python-version
*.egg
*.egg-info
@@ -35,9 +36,12 @@ __bundle__
coverage.xml
node_modules
lnbits/static/bundle.js
lnbits/static/bundle-components.js
lnbits/static/bundle.css
lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old
lnbits/static/bundle-components.min.js.old
lnbits/upgrades
docker
# Nix
@@ -49,8 +53,11 @@ fly.toml
lnbits-backup.zip
# Ignore extensions (post installable extension PR)
extensions
upgrades/
/lnbits/extensions
/lnbits/upgrades/
# builded python package
dist
# jetbrains
.idea
+1
View File
@@ -10,6 +10,7 @@
**/lnbits/static/vendor
**/lnbits/static/bundle.*
**/lnbits/static/bundle-components.*
**/lnbits/static/css/*
flake.lock
+39 -11
View File
@@ -1,4 +1,4 @@
FROM python:3.10-slim-bookworm
FROM python:3.12-slim-bookworm AS builder
RUN apt-get clean
RUN apt-get update
@@ -7,25 +7,53 @@ RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN mkdir data
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
FROM python:3.12-slim-bookworm
# needed for backups postgresql-client version 14 (pg_dump)
RUN apt install -y postgresql-common ca-certificates
RUN install -d /usr/share/postgresql-common/pgdg
RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc
RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
RUN apt-get update
RUN apt-get install -y postgresql-client-14
RUN apt-get update && apt-get -y upgrade && \
apt-get -y install gnupg2 curl lsb-release && \
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
curl -s https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
apt-get update && \
apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY . .
COPY --from=builder /app/.venv .venv
RUN mkdir data
RUN poetry install --only main
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+30
View File
@@ -0,0 +1,30 @@
FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:latest
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd
RUN apt-get update && apt-get -y upgrade && \
apt-get install -y netcat-openbsd
# Reinstall dependencies just in case (needed for CMD usage)
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
# LNbits + boltzd configuration
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
ENV FUNDING_SOURCE_MAX_RETRIES=10
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
ENV BOLTZ_CLIENT_WALLET="lnbits"
EXPOSE 5000
# Entrypoint to start boltzd and LNbits
COPY dockerboltz.sh /dockerboltz.sh
RUN chmod +x /dockerboltz.sh
CMD ["/dockerboltz.sh"]
+13 -7
View File
@@ -38,25 +38,32 @@ checkeditorconfig:
dev:
poetry run lnbits --reload
docker:
docker build -t lnbits/lnbits .
test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/wallets
test-unit:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/unit
test-api:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/api
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/regtest
@@ -71,6 +78,7 @@ test-migration:
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
LNBITS_ADMIN_UI=False \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
@@ -80,6 +88,7 @@ migration:
poetry run python tools/conv.py
openapi:
LNBITS_ADMIN_UI=False \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
@@ -99,24 +108,21 @@ sass:
bundle:
npm install
npm run sass
npm run vendor_copy
npm run vendor_json
npm run bundle
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
npm run vendor_bundle_css
npm run vendor_minify_css
npm run vendor_bundle_js
npm run vendor_minify_js
checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
make bundle
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
@echo "Bundle is OK"
rm lnbits/static/bundle.min.js.old
rm lnbits/static/bundle.min.css.old
rm lnbits/static/bundle-components.min.js.old
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -e
boltzd --standalone --referralId lnbits &
# Capture boltzd PID to monitor if needed
BOLTZ_PID=$!
# Wait for boltzd to start
for i in {1..10}; do
if nc -z localhost 9002; then
echo "boltzd is up!"
break
fi
echo "Waiting for boltzd to start..."
sleep 1
done
# Optional: check if still not up
if ! nc -z localhost 9002; then
echo "boltzd did not start successfully."
exit 1
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec poetry run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
+14 -2
View File
@@ -11,13 +11,16 @@ Thanks for contributing :)
# Run
This starts the lnbits uvicorn server
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install poetry and other dependencies.
Then you can start LNbits uvicorn server with:
```bash
poetry run lnbits
```
This starts the lnbits uvicorn with hot reloading.
Or you can use the following to start uvicorn with hot reloading enabled:
```bash
make dev
@@ -25,6 +28,15 @@ make dev
poetry run lnbits --reload
```
You might need the following extra dependencies on clean installation of Debian:
```
sudo apt install nodejs
sudo apt install npm
npm install
sudo apt-get install autoconf libtool libpg-dev
```
# Precommit hooks
This ensures that all commits adhere to the formatting and linting rules.
+1 -1
View File
@@ -67,7 +67,7 @@ You can access your super user account at `/wallet?usr=super_user_id`. You just
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.
Here you can design the interface, it has credit/debit to change wallets balances 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
+30
View File
@@ -0,0 +1,30 @@
# LNbits Funding Sources Comparison Table
LNbits can use a number of different Lightning Network funding source.
There may be trade-offs between the funding sources used, for example funding LNbits using Strike requires the user to KYC themselves and has some
privacy compromises versus running your own LND node. However the technical barrier to entry of using Strike is lower than using LND.
The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
## LNbits Lightning Network Funding Sources Comparison Table
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
| -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
| LND (gRPC) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
| CoreLightning (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
| Phoenixd | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
| Nostr Wallet Connect (NWC) | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
| Boltz | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
| LND (REST) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
| CoreLightning REST | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
| LNbits (another instance) | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
| Alby | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
| Breez SDK | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
| OpenNode | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
| Blink | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| ZBD | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| Spark (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| Cliche Wallet | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| Strike | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
+111 -48
View File
@@ -6,48 +6,56 @@ nav_order: 2
# Basic installation
You can choose between four package managers, `poetry` and `nix`
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
## Option 1: AppImage (LInux)
## Option 1 (recommended): poetry
### AppImage (Linux)
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX)
Make sure you have Python 3.9 or 3.10 installed.
### install python on ubuntu
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
```sh
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
sudo apt-get install libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
chmod +x LNbits-latest.AppImage
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
```
### install poetry
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
## Option 2: Poetry (recommended for developers)
It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
### Install Python 3.12
## Option 2 (recommended): Poetry
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
### Verify Python version
```sh
curl -sSL https://install.python-poetry.org | python3 -
# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
export PATH="/home/user/.local/bin:$PATH"
python3 --version
```
### Install Poetry
```sh
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
```
### install LNbits
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
poetry env use 3.12
git checkout main
# Next command, you can exchange with python3.10 or newer versions.
# Identify your version with python3 --version and specify in the next line
# command is only needed when your default python is not ^3.9 or ^3.10
poetry env use python3.9
poetry install --only main
mkdir data
cp .env.example .env
# set funding source amongst other options
nano .env
# Optional: to set funding source amongst other options via the env `nano .env`
```
#### Running the server
@@ -59,18 +67,50 @@ poetry run lnbits
# Note that you have to add the line DEBUG=true in your .env file, too.
```
#### LNbits-cli
```sh
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
poetry run lnbits-cli --help
```
#### Updating the server
```
```sh
cd lnbits
# Stop LNbits with `ctrl + x`
git pull
# Keep your poetry install up to date, this can be done with `poetry self update`
# Stop LNbits with `ctrl + x` or with service manager
# sudo systemctl stop lnbits
# Update LNbits
git pull --rebase
# Check your poetry version with
poetry env list
# If version is less 3.12, update it by running
poetry env use python3.12
poetry env remove python3.9
poetry env list
# Run install and start LNbits with
poetry install --only main
# Start LNbits with `poetry run lnbits`
poetry run lnbits
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
```
## Option 2: Nix
## Option 2: Install script (on Debian/Ubuntu)
```sh
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
chmod +x lnbits.sh &&
./lnbits.sh
```
Now visit `0.0.0.0:5000` to make a super-user account.
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
## Option 3: Nix
```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
@@ -108,9 +148,9 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
```
## Option 3: Docker
## Option 4: Docker
use latest version from docker hub
Use latest version from Docker Hub.
```sh
docker pull lnbits/lnbits
@@ -119,7 +159,15 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
build the image yourself
The LNbits Docker image comes with no extensions installed. User-installed extensions will be stored by default in a container directory.
It is recommended to point the `LNBITS_EXTENSIONS_PATH` environment variable to a directory that is mapped to a Docker volume. This way, the extensions will not be reinstalled when the container is destroyed.
Example:
```sh
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
```
Build the image yourself.
```sh
git clone https://github.com/lnbits/lnbits.git
@@ -130,7 +178,13 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
## Option 4: Fly.io
You can optionally override the arguments that are passed to `poetry install` during the build process by setting the Docker build argument named `POETRY_INSTALL_ARGS`. For example, to enable the Breez funding source, build the Docker image with the command:
```sh
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
```
## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
@@ -213,7 +267,7 @@ Problems installing? These commands have helped us install LNbits.
sudo apt install pkg-config libffi-dev libpq-dev
# build essentials for debian/ubuntu
sudo apt install python3.9-dev gcc build-essential
sudo apt install python3.10-dev gcc build-essential
# if the secp256k1 build fails:
# if you used poetry
@@ -346,15 +400,6 @@ Assuming your LNbits is running on port `5000` add:
```
yourdomain.com {
handle /api/v1/payments/sse* {
reverse_proxy 0.0.0.0:5000 {
header_up X-Forwarded-Host yourdomain.com
transport http {
keepalive off
compression off
}
}
}
reverse_proxy 0.0.0.0:5000 {
header_up X-Forwarded-Host yourdomain.com
}
@@ -373,7 +418,7 @@ Install Apache2 and enable Apache2 mods:
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy-http
a2enmod headers ssl proxy proxy_http
```
Create a SSL certificate with LetsEncrypt:
@@ -412,7 +457,7 @@ EOF
Restart Apache2:
```sh
service restart apache2
service apache2 restart
```
## Running behind an Nginx reverse proxy over HTTPS
@@ -444,6 +489,15 @@ server {
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_request_headers on;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
listen [::]:443 ssl;
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem;
@@ -457,7 +511,7 @@ EOF
Restart nginx:
```sh
service restart nginx
service nginx restart
```
## Using https without reverse proxy
@@ -537,3 +591,12 @@ docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_
```
Finally you can access your lnbits on your machine at port 5000.
### FreeBSD notes
Currently there is an issue with secp256k1 0.14.0 on FreeBSD. Thanks to @GitKalle
1. Install package `py311-secp256k1` with `pkg install py311-secp256k1`
2. Change version in `pyproject.toml` from 0.14.0 to 0.13.2
3. Rewrite `poetry.lock` file with command `poetry lock`
4. Follow install instruction with Poetry
+75 -1
View File
@@ -10,6 +10,25 @@ LNbits can run on top of many Lightning Network funding sources with more being
A backend wallet can be configured using the following LNbits environment variables:
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
[Core lightning Rest API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
- `LNBITS_BACKEND_WALLET_CLASS`: **CLNRestWallet**
- `CLNREST_URL`: `https://127.0.0.1:3010`
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the `ca.pem` file)
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the `server.pem` file)
- `CLNREST_READONLY_RUNE`: `lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune`
- `CLNREST_INVOICE_RUNE`: `lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune`
- `CLNREST_PAY_RUNE`: `lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_RENEPAY_RUNE`: `lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' `
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
### CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
@@ -17,6 +36,8 @@ A backend wallet can be configured using the following LNbits environment variab
### CoreLightning REST
This is the old REST interface that uses [Ride The Lightning/c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
@@ -51,7 +72,7 @@ You can also use an AES-encrypted macaroon (more info) instead by using
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
To encrypt your macaroon, run `poetry run lnbits-cli encrypt macaroon`.
### LNbits
@@ -76,6 +97,15 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey
### Blink
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
- `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
- `BLINK_TOKEN`: BlinkToken
### Alby
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
@@ -84,6 +114,18 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
### Boltz
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: lnbits
### ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
@@ -101,6 +143,38 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
### Breez SDK
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
- `BREEZ_API_KEY`: ...
- `BREEZ_GREENLIGHT_SEED`: ...
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
### Breez Liquid SDK
This funding source leverages the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage all Lightning payments via submarine swaps on the Liquid network. To get started, simply provide a mnemonic seed phrase. The easiest way to generate one is by using a liquid wallet, such as [Blockstream Green](https://blockstream.com/green/). Once generated, you can copy the seed to your environment variable or enter it in the admin UI.
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezLiquidSdkWallet**
- `BREEZ_LIQUID_SEED`: ...
Each submarine swap incurs service and on-chain fees. To account for these, you may need to increase the reserve fee in the admin UI by navigating to **Settings -> Funding**, or by setting the following environment variables:
- `LNBITS_RESERVE_FEE_MIN`: ...
- `LNBITS_RESERVE_FEE_PERCENT`: ...
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
### Nostr Wallet Connect (NWC)
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
Generated
+16 -16
View File
@@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
@@ -26,11 +26,11 @@
]
},
"locked": {
"lastModified": 1698974481,
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github"
},
"original": {
@@ -41,16 +41,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1702233072,
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
"lastModified": 1735563628,
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-23.11",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
@@ -66,11 +66,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1702334837,
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
"lastModified": 1724134185,
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"type": "github"
},
"original": {
@@ -122,11 +122,11 @@
]
},
"locked": {
"lastModified": 1699786194,
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
"lastModified": 1719749022,
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github"
},
"original": {
+12 -11
View File
@@ -2,7 +2,7 @@
description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
poetry2nix = {
url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
@@ -30,19 +30,20 @@
meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
coincurve = prev.coincurve.override { preferWheel = true; };
protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; };
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
pytest-md = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
types-mock = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
tlv8 = prev.tlv8.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.setuptools
];
});
pynostr = prev.pynostr.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.setuptools-scm
];
});
});
};
});
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Check install has not already run
if [ ! -d lnbits/data ]; then
# Update package list and install prerequisites non-interactively
sudo apt update -y
sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively
sudo add-apt-repository -y ppa:deadsnakes/ppa
# Install Python 3.10 and distutils non-interactively
sudo apt install -y python3.10 python3.10-distutils
# Install Poetry
curl -sSL https://install.python-poetry.org | python3.10 -
# Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
# Clone the LNbits repository
git clone https://github.com/lnbits/lnbits.git
if [ $? -ne 0 ]; then
echo "Failed to clone the repository ... FAIL"
exit 1
fi
# Ensure we are in the lnbits directory
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
git checkout main
# Make data folder
mkdir data
# Copy the .env.example to .env
cp .env.example .env
elif [ ! -d lnbits/wallets ]; then
# cd into lnbits
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
# Install the dependencies using Poetry
poetry env use python3.9
poetry install --only main
# Set environment variables for LNbits
export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Run LNbits
poetry run lnbits
+21
View File
@@ -0,0 +1,21 @@
from .core.services import create_invoice, pay_invoice
from .decorators import (
check_admin,
check_super_user,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from .exceptions import InvoiceError, PaymentError
__all__ = [
"InvoiceError",
"PaymentError",
"check_admin",
"check_super_user",
"check_user_exists",
"create_invoice",
"pay_invoice",
"require_admin_key",
"require_invoice_key",
]
+132 -70
View File
@@ -4,9 +4,10 @@ import importlib
import os
import shutil
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, List, Optional
from typing import Callable, Optional
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -14,15 +15,31 @@ from fastapi.staticfiles import StaticFiles
from loguru import logger
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import get_dbversions, get_installed_extensions
from lnbits.core.crud import (
get_db_version,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.tasks import ( # watchdog_task
killswitch_task,
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.tasks import (
audit_queue,
collect_exchange_rates_data,
purge_audit_data,
run_by_the_minute_tasks,
wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
@@ -40,41 +57,35 @@ from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases
from .core import init_core_routers
from .core.db import core_app_extra
from .core.models.extensions import Extension, ExtensionMeta, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension
from .core.views.generic import update_installed_extension_state
from .extension_manager import (
Extension,
InstallableExtension,
get_valid_extensions,
version_parse,
)
from .middleware import (
CustomGZipMiddleware,
AuditMiddleware,
ExtensionsRedirectMiddleware,
InstalledExtensionMiddleware,
add_first_install_middleware,
add_ip_block_middleware,
add_ratelimit_middleware,
)
from .requestvars import g
from .tasks import (
check_pending_payments,
internal_invoice_listener,
invoice_listener,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
async def startup(app: FastAPI):
logger.info(f"Starting LNbits Version: {settings.version}")
start = time.perf_counter()
settings.lnbits_running = True
# wait till migration is done
await migrate_databases()
# setup admin settings
await check_admin_settings()
core_app_extra.register_new_ratelimiter()
await check_webpush_settings()
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
await check_and_register_extensions(app)
log_server_info()
# initialize WALLET
@@ -90,17 +101,30 @@ async def startup(app: FastAPI):
# register core routes
init_core_routers(app)
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
await check_installed_extensions(app)
register_all_ext_routes(app)
# initialize tasks
register_async_tasks()
enqueue_admin_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server started.",
"up_time": settings.lnbits_server_up_time,
},
)
end = time.perf_counter()
logger.success(f"LNbits started in {end - start:.2f} seconds.")
async def shutdown():
logger.warning("LNbits shutting down...")
enqueue_admin_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server shutting down...",
"up_time": settings.lnbits_server_up_time,
},
)
settings.lnbits_running = False
# shutdown event
@@ -140,19 +164,20 @@ def create_app() -> FastAPI:
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
# register static files
static_path = Path("lnbits", "static")
static = StaticFiles(directory=static_path)
app.mount("/static", static, name="static")
g().base_url = f"http://{settings.host}:{settings.port}"
app.mount("/static", StaticFiles(directory=Path("lnbits", "static")), name="static")
Path(settings.lnbits_data_folder, "images").mkdir(parents=True, exist_ok=True)
app.mount(
"/library",
StaticFiles(directory=Path(settings.lnbits_data_folder, "images")),
name="library",
)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
)
app.add_middleware(AuditMiddleware, audit_queue=audit_queue)
# required for SSO login
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
@@ -207,7 +232,7 @@ async def check_funding_source() -> None:
break
retry_counter += 1
sleep_time = 0.25 * (2**retry_counter)
sleep_time = min(0.25 * (2**retry_counter), 60)
logger.warning(
f"Retrying connection to backend in {sleep_time} seconds... "
f"({retry_counter}/{max_retries})"
@@ -230,7 +255,7 @@ async def check_installed_extensions(app: FastAPI):
re-created. The 'data' directory (where the '.zip' files live) is expected to
persist state. Zips that are missing will be re-downloaded.
"""
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
installed_extensions = await build_all_installed_extensions_list(False)
for ext in installed_extensions:
@@ -244,6 +269,7 @@ async def check_installed_extensions(app: FastAPI):
)
except Exception as e:
logger.warning(e)
await deactivate_extension(ext.id)
logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
)
@@ -253,18 +279,37 @@ async def check_installed_extensions(app: FastAPI):
logger.info(f"{ext.id} ({ext.installed_version})")
async def build_all_installed_extensions_list(
async def build_all_installed_extensions_list( # noqa: C901
include_deactivated: Optional[bool] = True,
) -> List[InstallableExtension]:
) -> list[InstallableExtension]:
"""
Returns a list of all the installed extensions plus the extensions that
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
"""
installed_extensions = await get_installed_extensions()
settings.lnbits_installed_extensions_ids = {e.id for e in installed_extensions}
for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
try:
if not ext_dir.is_dir():
continue
ext_id = ext_dir.name
if ext_id in settings.lnbits_installed_extensions_ids:
continue
ext_info = InstallableExtension.from_ext_dir(ext_id)
if not ext_info:
continue
installed_extensions.append(ext_info)
await create_installed_extension(ext_info)
current_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, current_version)
except Exception as e:
logger.warning(e)
installed_extensions_ids = [e.id for e in installed_extensions]
for ext_id in settings.lnbits_extensions_default_install:
if ext_id in installed_extensions_ids:
if ext_id in settings.lnbits_installed_extensions_ids:
continue
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
@@ -275,8 +320,13 @@ async def build_all_installed_extensions_list(
release = next((e for e in ext_releases if e.is_version_compatible), None)
if release:
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension(
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
id=ext_id,
name=ext_id,
version=release.version,
icon=release.icon,
meta=ext_meta,
)
installed_extensions.append(ext_info)
@@ -307,22 +357,24 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
await add_installed_extension(ext)
await update_installed_extension_state(ext_id=ext.id, active=True)
extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension)
current_version = (await get_dbversions()).get(ext.id, 0)
await migrate_extension_database(extension, current_version)
current_version = await get_db_version(ext.id)
await migrate_extension_database(ext, current_version)
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
if extension.upgrade_hash:
ext.notify_upgrade()
def register_custom_extensions_path():
upgrades_dir = settings.lnbits_extensions_upgrade_path
shutil.rmtree(upgrades_dir, True)
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
if settings.has_default_extension_path:
return
default_ext_path = os.path.join("lnbits", "extensions")
@@ -337,8 +389,9 @@ def register_custom_extensions_path():
+ f" '{settings.lnbits_extensions_path}/extensions'"
)
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
extensions_dir = Path(settings.lnbits_extensions_path, "extensions")
Path(extensions_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(extensions_dir))
def register_new_ext_routes(app: FastAPI) -> Callable:
@@ -364,16 +417,21 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
return register_new_ratelimiter_fn
def register_ext_tasks(ext: Extension) -> None:
"""Register extension async tasks."""
ext_module = importlib.import_module(ext.module_name)
if hasattr(ext_module, f"{ext.code}_start"):
ext_start_func = getattr(ext_module, f"{ext.code}_start")
ext_start_func()
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension."""
ext_module = importlib.import_module(ext.module_name)
ext_route = getattr(ext_module, f"{ext.code}_ext")
if hasattr(ext_module, f"{ext.code}_start"):
ext_start_func = getattr(ext_module, f"{ext.code}_start")
ext_start_func()
if hasattr(ext_module, f"{ext.code}_static_files"):
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics:
@@ -382,43 +440,47 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
)
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
settings.lnbits_extensions_redirects = [
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code
]
for r in ext_redirects:
r["ext_id"] = ext.code
settings.lnbits_extensions_redirects.append(r)
ext_redirects = (
getattr(ext_module, f"{ext.code}_redirect_paths")
if hasattr(ext_module, f"{ext.code}_redirect_paths")
else []
)
logger.trace(f"adding route for extension {ext_module}")
settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
logger.trace(f"Adding route for extension {ext_module}.")
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
app.include_router(router=ext_route, prefix=prefix)
def register_all_ext_routes(app: FastAPI):
for ext in get_valid_extensions(False):
async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
try:
register_ext_routes(app, ext)
except Exception as e:
logger.error(f"Could not load extension `{ext.code}`: {e!s}")
register_ext_tasks(ext)
except Exception as exc:
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
def register_async_tasks():
create_permanent_task(check_pending_payments)
def register_async_tasks() -> None:
create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages)
create_permanent_task(run_interval(30 * 60, check_pending_payments))
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
# core invoice listener
invoice_queue = asyncio.Queue(5)
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
# TODO: implement watchdog properly
# create_permanent_task(watchdog_task)
create_permanent_task(killswitch_task)
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
# server logs for websocket
if settings.lnbits_admin_ui:
+161 -114
View File
@@ -1,10 +1,12 @@
import asyncio
import importlib
import sys
import time
from functools import wraps
from getpass import getpass
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.parse import urlparse
from typing import Optional
from uuid import uuid4
import click
import httpx
@@ -12,38 +14,36 @@ from fastapi.exceptions import HTTPException
from loguru import logger
from packaging import version
from lnbits.core.models import Payment, User
from lnbits.core.services import check_admin_settings
from lnbits.core import db as core_db
from lnbits.core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_db_versions,
get_installed_extension,
get_installed_extensions,
get_payment,
get_payments,
remove_deleted_wallets,
update_payment,
)
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Account, Payment, PaymentState
from lnbits.core.models.extensions import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.services import check_admin_settings, create_user_account_no_ckeck
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from lnbits.wallets.base import Wallet
from .core import db as core_db
from .core import migrations as core_migrations
from .core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_dbversions,
get_inactive_extensions,
get_installed_extension,
get_installed_extensions,
get_payments,
remove_deleted_wallets,
update_payment_status,
)
from .core.helpers import migrate_extension_database, run_migration
from .db import COCKROACH, POSTGRES, SQLITE
from .extension_manager import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
get_valid_extensions,
)
from lnbits.wallets.macaroon import load_macaroon
def coro(f):
@@ -68,6 +68,13 @@ def db():
"""
@lnbits_cli.group()
def users():
"""
Users related commands
"""
@lnbits_cli.group()
def extensions():
"""
@@ -75,6 +82,20 @@ def extensions():
"""
@lnbits_cli.group()
def encrypt():
"""
Encryption commands
"""
@lnbits_cli.group()
def decrypt():
"""
Decryption commands
"""
def get_super_user() -> Optional[str]:
"""Get the superuser"""
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
@@ -113,7 +134,7 @@ async def delete_settings():
"""Deletes the settings"""
async with core_db.connect() as conn:
await conn.execute("DELETE from settings")
await conn.execute("DELETE from system_settings")
@db.command("migrate")
@@ -123,53 +144,12 @@ def database_migrate():
loop.run_until_complete(migrate_databases())
async def db_migrate():
task = asyncio.create_task(migrate_databases())
await task
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
@db.command("versions")
@coro
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
click.echo(await get_dbversions(conn))
click.echo(await get_db_versions(conn))
@db.command("cleanup-wallets")
@@ -216,21 +196,13 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
@db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro
async def database_revert_payment(checking_id: str, pending: bool = True):
"""Mark wallet as deleted"""
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
@db.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_accounts(days: Optional[int] = None):
"""Delete all accounts that have no wallets"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
delta = delta * 24 * 60 * 60
await delete_accounts_no_wallets(delta, conn)
payment = await get_payment(checking_id=checking_id, conn=conn)
payment.status = PaymentState.PENDING
await update_payment(payment, conn=conn)
click.echo(f"Payment '{checking_id}' marked as pending.")
@db.command("check-payments")
@@ -276,7 +248,7 @@ async def check_invalid_payments(
click.echo("Funding source: " + str(funding_source))
# payments that are settled in the DB, but not at the Funding source level
invalid_payments: List[Payment] = []
invalid_payments: list[Payment] = []
invalid_wallets = {}
for db_payment in settled_db_payments:
if verbose:
@@ -313,10 +285,30 @@ async def check_invalid_payments(
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_inactive_extensions()
settings.lnbits_deactivated_extensions += inactive_extensions
@users.command("new")
@click.option("-u", "--username", required=True, help="Username.")
@click.option("-p", "--password", required=True, help="Password.")
@coro
async def create_user(username: str, password: str):
"""Create a new user bypassing the system 'new_accounts_allowed' rules"""
account = Account(
id=uuid4().hex,
username=username,
)
account.hash_password(password)
user = await create_user_account_no_ckeck(account)
click.echo(f"User '{user.username}' created. Id: '{user.id}'")
@users.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_accounts(days: Optional[int] = None):
"""Delete all accounts that have no wallets"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
delta = delta * 24 * 60 * 60
await delete_accounts_no_wallets(delta, conn)
@extensions.command("list")
@@ -328,8 +320,9 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list():
assert ext.installed_release, f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.installed_release.version})")
if not ext.meta or not ext.meta.installed_release:
raise ValueError(f"Extension {ext.id} has no installed_release")
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
@extensions.command("update")
@@ -359,7 +352,7 @@ async def extensions_list():
help="Admin user ID (must have permissions to install extensions).",
)
@coro
async def extensions_update(
async def extensions_update( # noqa: C901
extension: Optional[str] = None,
all_extensions: Optional[bool] = False,
repo_index: Optional[str] = None,
@@ -378,19 +371,24 @@ async def extensions_update(
if extension and all_extensions:
click.echo("Only one of extension ID or the '--all' flag must be specified")
return
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
if not await _can_run_operation(url):
return
upgrades_dir = settings.lnbits_extensions_upgrade_path
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
if extension:
await update_extension(extension, repo_index, source_repo, url, admin_user)
return
click.echo("Updating all extensions...")
click.echo("Updating all extensions:")
installed_extensions = await get_installed_extensions()
click.echo(f" {[e.id for e in installed_extensions]}")
updated_extensions = []
for e in installed_extensions:
try:
@@ -452,7 +450,7 @@ async def extensions_install(
):
"""Install a extension"""
click.echo(f"Installing {extension}... {repo_index}")
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -480,7 +478,7 @@ async def extensions_uninstall(
"""Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...")
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -497,6 +495,62 @@ async def extensions_uninstall(
return False, str(ex)
@encrypt.command("macaroon")
def encrypt_macaroon():
"""Encrypts a macaroon (LND wallets)"""
_macaroon = getpass("Enter macaroon: ")
try:
macaroon = load_macaroon(_macaroon)
except Exception as ex:
click.echo(f"Error loading macaroon: {ex}")
return
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted_macaroon = aes.encrypt(bytes.fromhex(macaroon))
except Exception as ex:
click.echo(f"Error encrypting macaroon: {ex}")
return
click.echo("Encrypted macaroon: ")
click.echo(encrypted_macaroon)
@encrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
)
def encrypt_aes(payload: str, urlsafe: bool = False):
"""AES encrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error encrypting payload: {ex}")
return
click.echo("Encrypted payload: ")
click.echo(encrypted)
@decrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
)
def decrypt_aes(payload: str, urlsafe: bool = False):
"""AES decrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error decrypting payload: {ex}")
return
click.echo("Decrypted payload: ")
click.echo(decrypted)
def main():
"""main function"""
lnbits_cli()
@@ -512,7 +566,7 @@ async def install_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
try:
release = await _select_release(extension, repo_index, source_repo)
if not release:
@@ -541,7 +595,7 @@ async def update_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
try:
click.echo(f"Updating '{extension}' extension.")
installed_ext = await get_installed_extension(extension)
@@ -553,16 +607,17 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
assert (
installed_ext.installed_release
), "Cannot find previously installed release. Please uninstall first."
if not installed_ext.meta or not installed_ext.meta.installed_release:
raise ValueError(
"Cannot find previously installed release. Please uninstall first."
)
release = await _select_release(extension, repo_index, source_repo)
if not release:
return False, "No release selected."
if (
release.version == installed_ext.installed_version
and release.source_repo == installed_ext.installed_release.source_repo
and release.source_repo == installed_ext.meta.installed_release.source_repo
):
click.echo(f"Extension '{extension}' already up to date.")
return False, "Already up to date"
@@ -661,7 +716,7 @@ async def _call_install_extension(
)
resp.raise_for_status()
else:
await api_install_extension(data, User(id="mock_id"))
await api_install_extension(data)
async def _call_uninstall_extension(
@@ -675,7 +730,7 @@ async def _call_uninstall_extension(
)
resp.raise_for_status()
else:
await api_uninstall_extension(extension, User(id="mock_id"))
await api_uninstall_extension(extension)
async def _can_run_operation(url) -> bool:
@@ -694,7 +749,7 @@ async def _can_run_operation(url) -> bool:
elif url:
click.echo(
"The option '--url' has been provided,"
+ f" but no server found runnint at '{url}'"
f" but no server found running at '{url}'"
)
return False
@@ -709,11 +764,3 @@ async def _is_lnbits_started(url: Optional[str]):
return True
except Exception:
return False
def _is_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+12 -3
View File
@@ -3,14 +3,17 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db
from .views.admin_api import admin_router
from .views.api import api_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension
from .views.generic import generic_router, update_user_extension
from .views.generic import generic_router
from .views.lnurl_api import lnurl_router
from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router
from .views.public_api import public_router
from .views.tinyurl_api import tinyurl_router
from .views.user_api import users_router
from .views.wallet_api import wallet_router
@@ -30,11 +33,17 @@ def init_core_routers(app: FastAPI):
app.include_router(extension_router)
app.include_router(super_node_router)
app.include_router(public_node_router)
app.include_router(public_router)
app.include_router(payment_router)
app.include_router(wallet_router)
app.include_router(api_router)
app.include_router(websocket_router)
app.include_router(callback_router)
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
__all__ = ["core_app", "core_app_extra", "db"]
-1311
View File
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
from .audit import create_audit_entry
from .db_versions import (
delete_dbversion,
get_db_version,
get_db_versions,
update_migration_version,
)
from .extensions import (
create_installed_extension,
create_user_extension,
delete_installed_extension,
drop_extension_db,
get_installed_extension,
get_installed_extensions,
get_user_active_extensions_ids,
get_user_extension,
get_user_extensions,
update_installed_extension,
update_installed_extension_state,
update_user_extension,
)
from .payments import (
DateTrunc,
check_internal,
create_payment,
delete_expired_invoices,
delete_wallet_payment,
get_latest_payments_by_extension,
get_payment,
get_payments,
get_payments_history,
get_payments_paginated,
get_standalone_payment,
get_wallet_payment,
is_internal_status_success,
mark_webhook_sent,
update_payment,
update_payment_checking_id,
update_payment_extra,
)
from .settings import (
create_admin_settings,
delete_admin_settings,
get_admin_settings,
get_super_settings,
reset_core_settings,
update_admin_settings,
update_super_user,
)
from .tinyurl import create_tinyurl, delete_tinyurl, get_tinyurl, get_tinyurl_by_url
from .users import (
create_account,
delete_account,
delete_accounts_no_wallets,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email,
get_accounts,
get_user,
get_user_access_control_lists,
get_user_from_account,
update_account,
)
from .wallets import (
create_wallet,
delete_unused_wallets,
delete_wallet,
delete_wallet_by_id,
force_delete_wallet,
get_total_balance,
get_wallet,
get_wallet_for_key,
get_wallets,
remove_deleted_wallets,
update_wallet,
)
from .webpush import (
create_webpush_subscription,
delete_webpush_subscription,
delete_webpush_subscriptions,
get_webpush_subscription,
get_webpush_subscriptions_for_user,
)
__all__ = [
"DateTrunc",
"check_internal",
"create_account",
"create_admin_settings",
"create_audit_entry",
"create_installed_extension",
"create_payment",
"create_tinyurl",
"create_user_extension",
"create_wallet",
"create_webpush_subscription",
"delete_account",
"delete_accounts_no_wallets",
"delete_admin_settings",
"delete_dbversion",
"delete_expired_invoices",
"delete_installed_extension",
"delete_tinyurl",
"delete_unused_wallets",
"delete_wallet",
"delete_wallet_by_id",
"delete_wallet_payment",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"drop_extension_db",
"force_delete_wallet",
"get_account",
"get_account_by_email",
"get_account_by_pubkey",
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_admin_settings",
"get_db_version",
"get_db_versions",
"get_installed_extension",
"get_installed_extensions",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
"get_payments_history",
"get_payments_paginated",
"get_standalone_payment",
"get_super_settings",
"get_tinyurl",
"get_tinyurl_by_url",
"get_total_balance",
"get_user",
"get_user_access_control_lists",
"get_user_active_extensions_ids",
"get_user_extension",
"get_user_extensions",
"get_user_from_account",
"get_wallet",
"get_wallet_for_key",
"get_wallet_payment",
"get_wallets",
"get_webpush_subscription",
"get_webpush_subscriptions_for_user",
"is_internal_status_success",
"mark_webhook_sent",
"remove_deleted_wallets",
"reset_core_settings",
"update_account",
"update_admin_settings",
"update_installed_extension",
"update_installed_extension_state",
"update_migration_version",
"update_payment",
"update_payment_checking_id",
"update_payment_extra",
"update_super_user",
"update_user_extension",
"update_wallet",
]
+90
View File
@@ -0,0 +1,90 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditCountStat
from lnbits.db import Connection, Filters, Page
async def create_audit_entry(
entry: AuditEntry,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("audit", entry)
async def get_audit_entries(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AuditEntry]:
return await (conn or db).fetch_page(
"SELECT * from audit",
[],
{},
filters=filters,
model=AuditEntry,
)
async def delete_expired_audit_entries(
conn: Optional[Connection] = None,
):
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE from audit
WHERE delete_at < {db.timestamp_now}
""", # noqa: S608
)
async def get_count_stats(
field: str,
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if field not in ["request_method", "component", "response_code"]:
return []
if not filters:
filters = Filters()
clause = filters.where()
data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f"""
SELECT {field} as field, count({field}) as total
FROM audit
{clause}
GROUP BY {field}
ORDER BY {field}
""", # noqa: S608
values=filters.values(),
model=AuditCountStat,
)
return data
async def get_long_duration_stats(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if not filters:
filters = Filters()
clause = filters.where()
long_duration_paths = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
query=f"""
SELECT path as field, max(duration) as total FROM audit
{clause}
GROUP BY path
ORDER BY total DESC
LIMIT 5
""", # noqa: S608
values=filters.values(),
model=AuditCountStat,
)
return long_duration_paths
+39
View File
@@ -0,0 +1,39 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.db import Connection
from ..models import DbVersion
async def get_db_version(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[DbVersion]:
return await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :ext_id",
{"ext_id": ext_id},
model=DbVersion,
)
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
async def update_migration_version(conn, db_name, version):
await (conn or db).execute(
"""
INSERT INTO dbversions (db, version) VALUES (:db, :version)
ON CONFLICT (db) DO UPDATE SET version = :version
""",
{"db": db_name, "version": version},
)
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"""
DELETE FROM dbversions WHERE db = :ext
""",
{"ext": ext_id},
)
+141
View File
@@ -0,0 +1,141 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
UserExtension,
)
from lnbits.db import Connection, Database
async def create_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("installed_extensions", ext)
async def update_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update("installed_extensions", ext)
async def update_installed_extension_state(
*, ext_id: str, active: bool, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
UPDATE installed_extensions SET active = :active WHERE id = :ext
""",
{"ext": ext_id, "active": active},
)
async def delete_installed_extension(
*, ext_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
DELETE from installed_extensions WHERE id = :ext
""",
{"ext": ext_id},
)
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
row: dict = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :id",
{"id": ext_id},
)
# Check that 'ext_id' is a valid extension id and not a malicious string
if not row:
raise Exception(f"Extension '{ext_id}' db version cannot be found")
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
return
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
# The `ext_id` value is verified above.
await (conn or db).execute(
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
)
async def get_installed_extension(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[InstallableExtension]:
extension = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = :id",
{"id": ext_id},
InstallableExtension,
)
return extension
async def get_installed_extensions(
active: Optional[bool] = None,
conn: Optional[Connection] = None,
) -> list[InstallableExtension]:
query = "SELECT * FROM installed_extensions"
if active is not None:
query += " WHERE active = :active"
values = {"active": active} if active is not None else {}
all_extensions = await (conn or db).fetchall(
query,
values,
model=InstallableExtension,
)
return all_extensions
async def get_user_extension(
user_id: str, extension: str, conn: Optional[Connection] = None
) -> Optional[UserExtension]:
return await (conn or db).fetchone(
"""
SELECT * FROM extensions
WHERE "user" = :user AND extension = :ext
""",
{"user": user_id, "ext": extension},
model=UserExtension,
)
async def get_user_extensions(
user_id: str, conn: Optional[Connection] = None
) -> list[UserExtension]:
return await (conn or db).fetchall(
"""SELECT * FROM extensions WHERE "user" = :user""",
{"user": user_id},
model=UserExtension,
)
async def create_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
await (conn or db).insert("extensions", user_extension)
async def update_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
where = """WHERE extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where)
async def get_user_active_extensions_ids(
user_id: str, conn: Optional[Connection] = None
) -> list[str]:
exts = await (conn or db).fetchall(
"""
SELECT * FROM extensions WHERE "user" = :user AND active
""",
{"user": user_id},
UserExtension,
)
return [ext.extension for ext in exts]
+573
View File
@@ -0,0 +1,573 @@
from time import time
from typing import Any, Optional
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.db import Connection, DateTrunc, Filters, Page
from ..models import (
CreatePayment,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentWalletStats,
)
def update_payment_extra():
pass
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
return await (conn or db).fetchone(
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
{"checking_id": checking_id},
Payment,
)
async def get_standalone_payment(
checking_id_or_hash: str,
conn: Optional[Connection] = None,
incoming: Optional[bool] = False,
wallet_id: Optional[str] = None,
) -> Optional[Payment]:
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = {
"wallet_id": wallet_id,
"checking_id": checking_id_or_hash,
"hash": checking_id_or_hash,
}
if incoming:
clause = f"({clause}) AND amount > 0"
if wallet_id:
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
f"""
SELECT * FROM apipayments
WHERE {clause}
ORDER BY amount LIMIT 1
""", # noqa: S608
values,
Payment,
)
return row
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
payment = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash
""",
{"wallet": wallet_id, "hash": payment_hash},
Payment,
)
return payment
async def get_latest_payments_by_extension(
ext_name: str, ext_id: str, limit: int = 5
) -> list[Payment]:
return await db.fetchall(
# This query is safe from SQL injection
# The limtit is an integer and not user input
f"""
SELECT * FROM apipayments
WHERE status = :status
AND extra LIKE :ext_name
AND extra LIKE :ext_id
ORDER BY time DESC LIMIT {int(limit)}
""", # noqa: S608
{
"status": f"{PaymentState.SUCCESS}",
"ext_name": f"%{ext_name}%",
"ext_id": f"%{ext_id}%",
},
Payment,
)
async def get_payments_paginated( # noqa: C901
*,
wallet_id: Optional[str] = None,
user_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
failed: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Payment]:
"""
Filters payments to be returned by:
- complete | pending | failed | outgoing | incoming.
"""
values: dict[str, Any] = {
"time": since,
}
clause: list[str] = []
if since is not None:
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
values["wallet_id"] = wallet_id
clause.append("wallet_id = :wallet_id")
elif user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
clause.append(only_user_wallets)
if complete and pending:
clause.append(
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
)
elif complete:
clause.append(
f"""
(
status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}')
)
"""
)
elif pending:
clause.append(f"status = '{PaymentState.PENDING}'")
elif failed:
clause.append(f"status = '{PaymentState.FAILED}'")
if outgoing and incoming:
pass
elif outgoing:
clause.append("amount < 0")
elif incoming:
clause.append("amount > 0")
else:
pass
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
clause.append("checking_id NOT LIKE 'temp_%'")
clause.append("checking_id NOT LIKE 'internal_%'")
return await (conn or db).fetch_page(
"SELECT * FROM apipayments",
clause,
values,
filters=filters,
model=Payment,
)
async def get_payments(
*,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
filters = filters or Filters()
filters.sortby = filters.sortby or "time"
filters.direction = filters.direction or "desc"
filters.limit = limit or filters.limit
filters.offset = offset or filters.offset
page = await get_payments_paginated(
wallet_id=wallet_id,
complete=complete,
pending=pending,
outgoing=outgoing,
incoming=incoming,
since=since,
exclude_uncheckable=exclude_uncheckable,
filters=filters,
conn=conn,
)
return page.data
async def get_payments_status_count() -> PaymentsStatusCount:
empty_page: Filters = Filters(limit=0)
in_payments = await get_payments_paginated(incoming=True, filters=empty_page)
out_payments = await get_payments_paginated(outgoing=True, filters=empty_page)
pending_payments = await get_payments_paginated(pending=True, filters=empty_page)
failed_payments = await get_payments_paginated(failed=True, filters=empty_page)
return PaymentsStatusCount(
incoming=in_payments.total,
outgoing=out_payments.total,
pending=pending_payments.total,
failed=failed_payments.total,
)
async def delete_expired_invoices(
conn: Optional[Connection] = None,
) -> None:
# first we delete all invoices older than one month
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = :status AND amount > 0
AND time < {db.timestamp_placeholder("delta")}
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
)
# then we delete all invoices whose expiry date is in the past
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = :status AND amount > 0
AND expiry < {db.timestamp_placeholder("now")}
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "now": int(time())},
)
async def create_payment(
checking_id: str,
data: CreatePayment,
status: PaymentState = PaymentState.PENDING,
conn: Optional[Connection] = None,
) -> Payment:
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn)
if previous_payment is not None:
raise ValueError("Payment already exists")
extra = data.extra or {}
payment = Payment(
checking_id=checking_id,
status=status,
wallet_id=data.wallet_id,
payment_hash=data.payment_hash,
bolt11=data.bolt11,
amount=data.amount_msat,
memo=data.memo,
preimage=data.preimage,
expiry=data.expiry,
webhook=data.webhook,
fee=-abs(data.fee),
tag=extra.get("tag", None),
extra=extra,
)
await (conn or db).insert("apipayments", payment)
return payment
async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
{"new_id": new_checking_id, "old_id": checking_id},
)
async def update_payment(
payment: Payment,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
async def get_payments_history(
wallet_id: Optional[str] = None,
group: DateTrunc = "day",
filters: Optional[Filters] = None,
) -> list[PaymentHistoryPoint]:
if not filters:
filters = Filters()
date_trunc = db.datetime_grouping(group)
values = {
"wallet_id": wallet_id,
}
# count outgoing payments if they are still pending
where = [
f"""
wallet_id = :wallet_id AND (
status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}')
)
"""
]
clause = filters.where(where)
transactions: list[dict] = await db.fetchall(
# This query is safe from SQL injection:
# - `date_trunc` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
f"""
SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments
{clause}
GROUP BY date
ORDER BY date DESC
""", # noqa: S608
filters.values(values),
)
if wallet_id:
wallet = await get_wallet(wallet_id)
if wallet:
balance = wallet.balance_msat
else:
raise ValueError("Unknown wallet")
else:
balance = await get_total_balance()
# since we dont know the balance at the starting point,
# we take the current balance and walk backwards
results: list[PaymentHistoryPoint] = []
for row in transactions:
results.insert(
0,
PaymentHistoryPoint(
balance=balance,
date=row.get("date", 0),
income=row.get("income", 0),
spending=row.get("spending", 0),
),
)
balance -= row.get("income", 0) - row.get("spending", 0)
return results
async def get_payment_count_stats(
field: PaymentCountField,
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
extra_stmts = []
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
extra_stmts.append(only_user_wallets)
clause = filters.where(extra_stmts)
data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f"""
SELECT {field} as field, count(*) as total
FROM apipayments
{clause}
GROUP BY {field}
ORDER BY {field}
""", # noqa: S608
values=filters.values(),
model=PaymentCountStat,
)
return data
async def get_daily_stats(
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
out_where_stmts = [
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
in_where_stmts.append(only_user_wallets)
out_where_stmts.append(only_user_wallets)
in_clause = filters.where(in_where_stmts)
out_clause = filters.where(out_where_stmts)
date_trunc = db.datetime_grouping("day")
query = """
SELECT {date_trunc} date,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance,
ABS(SUM(apipayments.fee)) as fee,
COUNT(*) as payments_count
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
{clause}
AND (wallets.deleted = false OR wallets.deleted is NULL)
GROUP BY date
ORDER BY date ASC
"""
data_in = await (conn or db).fetchall(
query=query.format(date_trunc=date_trunc, clause=in_clause),
values=filters.values(),
model=PaymentDailyStats,
)
data_out = await (conn or db).fetchall(
query=query.format(date_trunc=date_trunc, clause=out_clause),
values=filters.values(),
model=PaymentDailyStats,
)
return data_in, data_out
async def get_wallets_stats(
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> list[PaymentWalletStats]:
if not filters:
filters = Filters()
where_stmts = [
"(wallets.deleted = false OR wallets.deleted is NULL)",
"""
(
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (
apipayments.status IN ('success', 'pending')
AND apipayments.amount < 0
)
)
""",
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
where_stmts.append(only_user_wallets)
clauses = filters.where(where_stmts)
data = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clauses` is constructed from sanitized inputs
query=f"""
SELECT apipayments.wallet_id,
MAX(wallets.name) AS wallet_name,
MAX(wallets.user) AS user_id,
COUNT(*) as payments_count,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
{clauses}
GROUP BY apipayments.wallet_id
ORDER BY payments_count
""", # noqa: S608
values=filters.values(),
model=PaymentWalletStats,
)
return data
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
{"checking_id": checking_id, "wallet": wallet_id},
)
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
"""
return await (conn or db).fetchone(
"""
SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = :status AND amount > 0
""",
{"status": f"{PaymentState.PENDING}", "hash": payment_hash},
Payment,
)
async def is_internal_status_success(
payment_hash: str, conn: Optional[Connection] = None
) -> bool:
"""
Returns True if the internal payment was found and is successful,
"""
payment = await (conn or db).fetchone(
"""
SELECT * FROM apipayments
WHERE payment_hash = :payment_hash AND amount > 0
""",
{"payment_hash": payment_hash},
Payment,
)
if not payment:
return False
return payment.status == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: str) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = :status
WHERE payment_hash = :hash
""",
{"status": status, "hash": payment_hash},
)
async def _only_user_wallets_statement(
user_id: str, conn: Optional[Connection] = None
) -> str:
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
"no-wallets-for-user"
]
# wallet ids are safe to use in sql queries
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
+140
View File
@@ -0,0 +1,140 @@
import json
from typing import Any, Optional
from loguru import logger
from lnbits.core.db import db
from lnbits.db import dict_to_model
from lnbits.settings import (
AdminSettings,
EditableSettings,
SettingsField,
SuperSettings,
settings,
)
async def get_super_settings() -> Optional[SuperSettings]:
data = await get_settings_by_tag("core")
if data:
super_user = await get_settings_field("super_user")
super_user_id = super_user.value if super_user else None
settings_dict = {"super_user": super_user_id, **data}
return dict_to_model(settings_dict, SuperSettings)
return None
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
sets = await get_super_settings()
if not sets:
return None
row_dict = dict(sets)
row_dict.pop("super_user")
row_dict.pop("auth_all_methods")
admin_settings = AdminSettings(
is_super_user=is_super_user,
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
**row_dict,
)
return admin_settings
async def update_admin_settings(
data: EditableSettings, tag: Optional[str] = "core"
) -> None:
editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True))
for key, value in editable_settings.items():
try:
await set_settings_field(key, value, tag)
except Exception as exc:
logger.warning(exc)
logger.warning(f"Failed to update settings for '{tag}.{key}'.")
async def update_super_user(super_user: str) -> SuperSettings:
await set_settings_field("super_user", super_user)
settings = await get_super_settings()
if not settings:
raise ValueError("updated super_user settings could not be retrieved")
return settings
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
await db.execute(
"DELETE FROM system_settings WHERE tag = :tag",
{"tag": tag},
)
async def reset_core_settings() -> None:
await db.execute(
"""
DELETE FROM system_settings WHERE tag = 'core'
AND id NOT IN (
'super_user',
'lnbits_webpush_pubkey',
'lnbits_webpush_privkey'
)
""",
)
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
data = {"super_user": super_user, **new_settings}
for key, value in data.items():
await set_settings_field(key, value)
settings = await get_super_settings()
if not settings:
raise ValueError("created admin settings could not be retrieved")
return settings
async def get_settings_field(
id_: str, tag: Optional[str] = "core"
) -> Optional[SettingsField]:
row: dict = await db.fetchone(
"""
SELECT * FROM system_settings
WHERE id = :id AND tag = :tag
""",
{"id": id_, "tag": tag},
)
if not row:
return None
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
async def set_settings_field(
id_: str, value: Optional[Any], tag: Optional[str] = "core"
):
value = json.dumps(value) if value is not None else None
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
ON CONFLICT (id, tag) DO UPDATE SET value = :value
""",
{"id": id_, "value": value, "tag": tag or "core"},
)
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
rows: list[dict] = await db.fetchall(
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
)
if len(rows) == 0:
return None
data: dict[str, Any] = {}
for row in rows:
try:
data[row["id"]] = json.loads(row["value"]) if row["value"] else None
except Exception as _:
logger.warning(
f"""Failed to load settings value for '{tag}.{row["id"]}'."""
)
data.pop("super_user")
return data
+42
View File
@@ -0,0 +1,42 @@
from typing import Optional
import shortuuid
from lnbits.core.db import db
from ..models import TinyURL
async def create_tinyurl(domain: str, endless: bool, wallet: str):
tinyurl_id = shortuuid.uuid()[:8]
await db.execute(
"""
INSERT INTO tiny_url (id, url, endless, wallet)
VALUES (:tinyurl, :domain, :endless, :wallet)
""",
{"tinyurl": tinyurl_id, "domain": domain, "endless": endless, "wallet": wallet},
)
return await get_tinyurl(tinyurl_id)
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
return await db.fetchone(
"SELECT * FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
TinyURL,
)
async def get_tinyurl_by_url(url: str) -> list[TinyURL]:
return await db.fetchall(
"SELECT * FROM tiny_url WHERE url = :url",
{"url": url},
TinyURL,
)
async def delete_tinyurl(tinyurl_id: str):
await db.execute(
"DELETE FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
)
+218
View File
@@ -0,0 +1,218 @@
from datetime import datetime, timezone
from time import time
from typing import Any, Optional
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import get_wallets
from lnbits.core.db import db
from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page
from ..models import (
Account,
AccountFilters,
AccountOverview,
User,
)
async def create_account(
account: Optional[Account] = None,
conn: Optional[Connection] = None,
) -> Account:
if account:
account.validate_fields()
else:
now = datetime.now(timezone.utc)
account = Account(id=uuid4().hex, created_at=now, updated_at=now)
await (conn or db).insert("accounts", account)
return account
async def update_account(account: Account) -> Account:
account.updated_at = datetime.now(timezone.utc)
await db.update("accounts", account)
return account
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE from accounts WHERE id = :user",
{"user": user_id},
)
async def get_accounts(
filters: Optional[Filters[AccountFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AccountOverview]:
where_clauses = []
values: dict[str, Any] = {}
# Make wallet filter explicit
wallet_filter = (
next((f for f in filters.filters if f.field == "wallet_id"), None)
if filters
else None
)
if filters and wallet_filter and wallet_filter.values:
where_clauses.append("wallets.id = :wallet_id")
values = {**values, "wallet_id": next(iter(wallet_filter.values.values()))}
filters.filters = [f for f in filters.filters if f.field != "wallet_id"]
return await (conn or db).fetch_page(
"""
SELECT
accounts.id,
accounts.username,
accounts.email,
accounts.pubkey,
accounts.external_id,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
SUM((
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
)) as transaction_count,
(
SELECT COUNT(*) FROM wallets WHERE wallets.user = accounts.id
) as wallet_count,
MAX((
SELECT time FROM apipayments
WHERE wallet_id = wallets.id ORDER BY time DESC LIMIT 1
)) as last_payment
FROM accounts LEFT JOIN wallets ON accounts.id = wallets.user
""",
where_clauses,
values,
filters=filters,
model=AccountOverview,
group_by=["accounts.id"],
)
async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(user_id) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE id = :id",
{"id": user_id},
Account,
)
async def delete_accounts_no_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM accounts
WHERE NOT EXISTS (
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
) AND (
(updated_at is null AND created_at < :delta)
OR updated_at < {db.timestamp_placeholder("delta")}
)
""", # noqa: S608
{"delta": delta},
)
async def get_account_by_username(
username: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(username) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(username) = :username",
{"username": username.lower()},
Account,
)
async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey.lower()},
Account,
)
async def get_account_by_email(
email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(email) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(email) = :email",
{"email": email.lower()},
Account,
)
async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE LOWER(email) = :value or LOWER(username) = :value
""",
{"value": username_or_email.lower()},
Account,
)
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
account = await get_account(user_id, conn)
if not account:
return None
return await get_user_from_account(account, conn)
async def get_user_from_account(
account: Account, conn: Optional[Connection] = None
) -> Optional[User]:
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
return User(
id=account.id,
email=account.email,
username=account.username,
pubkey=account.pubkey,
external_id=account.external_id,
extra=account.extra,
created_at=account.created_at,
updated_at=account.updated_at,
extensions=extensions,
wallets=wallets,
admin=account.is_admin,
super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None,
)
async def update_user_access_control_list(user_acls: UserAcls):
user_acls.updated_at = datetime.now(timezone.utc)
await db.update("accounts", user_acls)
async def get_user_access_control_lists(
user_id: str, conn: Optional[Connection] = None
) -> UserAcls:
user_acls = await (conn or db).fetchone(
"SELECT id, access_control_list FROM accounts WHERE id = :id",
{"id": user_id},
UserAcls,
)
return user_acls or UserAcls(id=user_id)
+207
View File
@@ -0,0 +1,207 @@
from datetime import datetime, timezone
from time import time
from typing import Optional
from uuid import uuid4
from lnbits.core.db import db
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings
from ..models import Wallet
async def create_wallet(
*,
user_id: str,
wallet_name: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Wallet:
wallet_id = uuid4().hex
wallet = Wallet(
id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name,
user=user_id,
adminkey=uuid4().hex,
inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD",
)
await (conn or db).insert("wallets", wallet)
return wallet
async def update_wallet(
wallet: Wallet,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet)
return wallet
async def delete_wallet(
*,
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Optional[Connection] = None,
) -> None:
now = int(time())
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet AND "user" = :user
""", # noqa: S608
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
)
async def force_delete_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
)
async def delete_wallet_by_id(
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[int]:
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet
""", # noqa: S608
{"wallet": wallet_id, "now": now},
)
return result.rowcount
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
async def delete_unused_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
"""
DELETE FROM wallets
WHERE (
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
) = 0 AND (
(updated_at is null AND created_at < :delta)
OR updated_at < :delta
)
""",
{"delta": delta},
)
async def get_wallet(
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Optional[Wallet]:
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchone(
query,
{"wallet": wallet_id, "deleted": deleted},
Wallet,
)
async def get_wallets(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[Wallet]:
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchall(
query,
{"user": user_id, "deleted": deleted},
Wallet,
)
async def get_wallets_paginated(
user_id: str,
deleted: Optional[bool] = None,
filters: Optional[Filters[WalletsFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Wallet]:
if deleted is None:
deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
return await (conn or db).fetch_page(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
""",
where=where,
values={"user": user_id, "deleted": deleted},
filters=filters,
model=Wallet,
)
async def get_wallets_ids(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[str]:
query = """SELECT id FROM wallets WHERE "user" = :user"""
if deleted is not None:
query += "AND deleted = :deleted"
result: list[dict] = await (conn or db).fetchall(
query,
{"user": user_id, "deleted": deleted},
)
return [row["id"] for row in result]
async def get_wallets_count():
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
row = result.mappings().first()
return row.get("count", 0)
async def get_wallet_for_key(
key: str,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
return await (conn or db).fetchone(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)
AS balance_msat FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
""",
{"key": key},
Wallet,
)
async def get_total_balance(conn: Optional[Connection] = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first()
return row.get("balance", 0) or 0
+60
View File
@@ -0,0 +1,60 @@
from typing import Optional
from lnbits.core.db import db
from ..models import WebPushSubscription
async def get_webpush_subscription(
endpoint: str, user: str
) -> Optional[WebPushSubscription]:
return await db.fetchone(
"""
SELECT * FROM webpush_subscriptions
WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
WebPushSubscription,
)
async def get_webpush_subscriptions_for_user(user: str) -> list[WebPushSubscription]:
return await db.fetchall(
"""SELECT * FROM webpush_subscriptions WHERE "user" = :user""",
{"user": user},
WebPushSubscription,
)
async def create_webpush_subscription(
endpoint: str, user: str, data: str, host: str
) -> WebPushSubscription:
await db.execute(
"""
INSERT INTO webpush_subscriptions (endpoint, "user", data, host)
VALUES (:endpoint, :user, :data, :host)
""",
{"endpoint": endpoint, "user": user, "data": data, "host": host},
)
subscription = await get_webpush_subscription(endpoint, user)
if not subscription:
raise ValueError("Newly created webpush subscription couldn't be retrieved")
return subscription
async def delete_webpush_subscription(endpoint: str, user: str) -> int:
resp = await db.execute(
"""
DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
)
return resp.rowcount
async def delete_webpush_subscriptions(endpoint: str) -> int:
resp = await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint",
{"endpoint": endpoint},
)
return resp.rowcount
+83 -72
View File
@@ -1,42 +1,52 @@
import importlib
import re
from typing import Any, Optional
from urllib.parse import urlparse
from uuid import UUID
import httpx
from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_db_versions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.db import Connection
from lnbits.extension_manager import Extension
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
from .crud import update_migration_version
async def migrate_extension_database(
ext: InstallableExtension, current_version: Optional[DbVersion] = None
):
async def migrate_extension_database(ext: Extension, current_version):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db
except ImportError as exc:
logger.error(exc)
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
) from exc
raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
async def run_migration(
db: Connection, migrations_module: Any, db_name: str, current_version: int
db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
):
matcher = re.compile(r"^m(\d\d\d)_")
for key, migrate in migrations_module.__dict__.items():
for key, migrate in list(migrations_module.__dict__.items()):
match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_version:
if not current_version or version > current_version.version:
logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}")
await migrate(db)
@@ -48,66 +58,6 @@ async def run_migration(
await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(
ext_id: str, user: str, access_token: Optional[str] = None
):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
at the root level of their API.
"""
stopped = await _stop_extension_background_work(ext_id)
if not stopped:
# fallback to REST API call
await _stop_extension_background_work_via_api(ext_id, user, access_token)
async def _stop_extension_background_work(ext_id) -> bool:
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
await getattr(old_module, stop_fn_name)()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
logger.info(
f"Stopping background work for extension '{ext_id}' using the REST API."
)
async with httpx.AsyncClient() as client:
try:
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
headers = (
{"Authorization": "Bearer " + access_token} if access_token else None
)
resp = await client.delete(url=url, headers=headers)
resp.raise_for_status()
logger.info(f"Stopped background work for extension '{ext_id}'.")
except Exception as ex:
logger.warning(
f"Failed to stop background work for '{ext_id}' using the REST API."
)
logger.warning(ex)
def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits")
@@ -117,3 +67,64 @@ def to_valid_user_id(user_id: str) -> UUID:
raise ValueError("Invalid hex string for User ID.") from exc
return UUID(hex=user_id[:32], version=4)
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_db_versions(conn)
core_version = next(
(v for v in current_versions if v.db == "core"),
DbVersion(db="core", version=0),
)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in await get_installed_extensions():
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
)
continue
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.id}: {e}")
logger.info("✔️ All migrations done.")
def is_valid_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+304 -81
View File
@@ -1,13 +1,15 @@
import datetime
import json
from time import time
from typing import Any
from loguru import logger
from sqlalchemy.exc import OperationalError
from lnbits import bolt11
from lnbits.db import Connection
async def m000_create_migrations_table(db):
async def m000_create_migrations_table(db: Connection):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS dbversions (
@@ -18,7 +20,7 @@ async def m000_create_migrations_table(db):
)
async def m001_initial(db):
async def m001_initial(db: Connection):
"""
Initial LNbits tables.
"""
@@ -87,7 +89,7 @@ async def m001_initial(db):
)
async def m002_add_fields_to_apipayments(db):
async def m002_add_fields_to_apipayments(db: Connection):
"""
Adding fields to apipayments for better accounting,
and renaming payhash to checking_id since that is what it really is.
@@ -100,9 +102,8 @@ async def m002_add_fields_to_apipayments(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
import json
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
result = await db.execute("SELECT * FROM apipayments")
rows = result.mappings().all()
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
@@ -113,15 +114,15 @@ async def m002_add_fields_to_apipayments(db):
new = row["memo"][len(prefix) :]
await db.execute(
"""
UPDATE apipayments SET extra = ?, memo = ?
WHERE checking_id = ? AND memo = ?
UPDATE apipayments SET extra = :extra, memo = :memo1
WHERE checking_id = :checking_id AND memo = :memo2
""",
(
json.dumps({"tag": ext}),
new,
row["checking_id"],
row["memo"],
),
{
"extra": json.dumps({"tag": ext}),
"memo1": new,
"checking_id": row["checking_id"],
"memo2": row["memo"],
},
)
break
except OperationalError:
@@ -132,7 +133,7 @@ async def m002_add_fields_to_apipayments(db):
pass
async def m003_add_invoice_webhook(db):
async def m003_add_invoice_webhook(db: Connection):
"""
Special column for webhook endpoints that can be assigned
to each different invoice.
@@ -142,7 +143,7 @@ async def m003_add_invoice_webhook(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT")
async def m004_ensure_fees_are_always_negative(db):
async def m004_ensure_fees_are_always_negative(db: Connection):
"""
Use abs() so wallet backends don't have to care about the sign of the fees.
"""
@@ -167,7 +168,7 @@ async def m004_ensure_fees_are_always_negative(db):
)
async def m005_balance_check_balance_notify(db):
async def m005_balance_check_balance_notify(db: Connection):
"""
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
@@ -197,7 +198,7 @@ async def m005_balance_check_balance_notify(db):
)
async def m006_add_invoice_expiry_to_apipayments(db):
async def m006_add_invoice_expiry_to_apipayments(db: Connection):
"""
Adds invoice expiry column to apipayments.
"""
@@ -207,24 +208,24 @@ async def m006_add_invoice_expiry_to_apipayments(db):
pass
async def m007_set_invoice_expiries(db):
async def m007_set_invoice_expiries(db: Connection):
"""
Precomputes invoice expiry for existing pending incoming payments.
"""
try:
rows = await (
await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
)
).fetchall()
result = await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
""" # noqa: S608
)
rows = result.mappings().all()
if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
@@ -236,24 +237,21 @@ async def m007_set_invoice_expiries(db):
if invoice.expiry is None:
continue
expiration_date = datetime.datetime.fromtimestamp(
invoice.date + invoice.expiry
)
expiration_date = invoice.date + invoice.expiry
logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
"""
UPDATE apipayments SET expiry = ?
WHERE checking_id = ? AND amount > 0
""",
(
db.datetime_to_timestamp(expiration_date),
checking_id,
),
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = :checking_id AND amount > 0
""", # noqa: S608
{"expiry": expiration_date, "checking_id": checking_id},
)
except Exception:
except Exception as exc:
logger.debug(exc)
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
@@ -263,7 +261,7 @@ async def m007_set_invoice_expiries(db):
pass
async def m008_create_admin_settings_table(db):
async def m008_create_admin_settings_table(db: Connection):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
@@ -274,7 +272,7 @@ async def m008_create_admin_settings_table(db):
)
async def m009_create_tinyurl_table(db):
async def m009_create_tinyurl_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS tiny_url (
@@ -288,7 +286,7 @@ async def m009_create_tinyurl_table(db):
)
async def m010_create_installed_extensions_table(db):
async def m010_create_installed_extensions_table(db: Connection):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS installed_extensions (
@@ -305,7 +303,7 @@ async def m010_create_installed_extensions_table(db):
)
async def m011_optimize_balances_view(db):
async def m011_optimize_balances_view(db: Connection):
"""
Make the calculation of the balance a single aggregation
over the payments table instead of 2.
@@ -322,7 +320,7 @@ async def m011_optimize_balances_view(db):
)
async def m012_add_currency_to_wallet(db):
async def m012_add_currency_to_wallet(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN currency TEXT
@@ -330,7 +328,7 @@ async def m012_add_currency_to_wallet(db):
)
async def m013_add_deleted_to_wallets(db):
async def m013_add_deleted_to_wallets(db: Connection):
"""
Adds deleted column to wallets.
"""
@@ -342,36 +340,42 @@ async def m013_add_deleted_to_wallets(db):
pass
async def m014_set_deleted_wallets(db):
async def m014_set_deleted_wallets(db: Connection):
"""
Sets deleted column to wallets.
"""
try:
rows = await (
await db.execute(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
).fetchall()
result = await db.execute(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
rows = result.mappings().all()
for row in rows:
try:
user = row[2].split(":")[1]
adminkey = row[3].split(":")[1]
inkey = row[4].split(":")[1]
user = row["user"].split(":")[1]
adminkey = row["adminkey"].split(":")[1]
inkey = row["inkey"].split(":")[1]
await db.execute(
"""
UPDATE wallets SET user = ?, adminkey = ?, inkey = ?, deleted = true
WHERE id = ?
UPDATE wallets SET
"user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
WHERE id = :wallet
""",
(user, adminkey, inkey, row[0]),
{
"user": user,
"adminkey": adminkey,
"inkey": inkey,
"wallet": row.get("id"),
},
)
except Exception:
except Exception as exc:
logger.debug(exc)
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
@@ -381,7 +385,7 @@ async def m014_set_deleted_wallets(db):
pass
async def m015_create_push_notification_subscriptions_table(db):
async def m015_create_push_notification_subscriptions_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
@@ -396,7 +400,7 @@ async def m015_create_push_notification_subscriptions_table(db):
)
async def m016_add_username_column_to_accounts(db):
async def m016_add_username_column_to_accounts(db: Connection):
"""
Adds username column to accounts.
"""
@@ -407,7 +411,7 @@ async def m016_add_username_column_to_accounts(db):
pass
async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
"""
Adds created_at and updated_at column to accounts and wallets.
"""
@@ -454,18 +458,20 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
# set all to now where they are null
now = int(time())
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder}
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""",
(now,),
""", # noqa: S608
{"now": now},
)
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder}
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""",
(now,),
""", # noqa: S608
{"now": now},
)
except OperationalError as exc:
@@ -473,7 +479,7 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
pass
async def m018_balances_view_exclude_deleted(db):
async def m018_balances_view_exclude_deleted(db: Connection):
"""
Make deleted wallets not show up in the balances view.
"""
@@ -493,7 +499,7 @@ async def m018_balances_view_exclude_deleted(db):
)
async def m019_balances_view_based_on_wallets(db):
async def m019_balances_view_based_on_wallets(db: Connection):
"""
Make deleted wallets not show up in the balances view.
Important for querying whole lnbits balances.
@@ -512,3 +518,220 @@ async def m019_balances_view_based_on_wallets(db):
GROUP BY apipayments.wallet
"""
)
async def m020_add_column_column_to_user_extensions(db: Connection):
"""
Adds extra column to user extensions.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
async def m021_add_success_failed_to_apipayments(db: Connection):
"""
Adds success and failed columns to apipayments.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN status TEXT DEFAULT 'pending'")
# set all not pending to success true, failed payments were deleted until now
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet
"""
)
async def m022_add_pubkey_to_accounts(db: Connection):
"""
Adds pubkey column to accounts.
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
except OperationalError:
pass
async def m023_add_column_column_to_apipayments(db: Connection):
"""
renames hash to payment_hash and drops unused index
"""
await db.execute("DROP INDEX by_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN hash TO payment_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN wallet TO wallet_id")
await db.execute("ALTER TABLE accounts RENAME COLUMN pass TO password_hash")
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
async def m024_drop_pending(db: Connection):
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m025_refresh_view(db: Connection):
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet_id,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet_id
"""
)
async def m026_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN tag TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extension TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN created_at TIMESTAMP")
await db.execute("ALTER TABLE apipayments ADD COLUMN updated_at TIMESTAMP")
async def m027_update_apipayments_data(db: Connection):
result = None
try:
result = await db.execute("SELECT * FROM apipayments LIMIT 100")
except Exception as exc:
logger.warning("Could not select, trying again after cache cleared.")
logger.debug(exc)
await db.execute("COMMIT")
offset = 0
limit = 1000
payments: list[dict[Any, Any]] = []
logger.info("Updating payments")
while len(payments) > 0 or offset == 0:
logger.info(f"Updating {offset} to {offset+limit}")
result = await db.execute(
# Limit and Offset safe from SQL injection
# since they are integers and are not user input
f"""
SELECT * FROM apipayments
ORDER BY time LIMIT {int(limit)} OFFSET {int(offset)}
""" # noqa: S608
)
payments = result.mappings().all()
logger.info(f"Payments count: {len(payments)}")
for payment in payments:
tag = None
created_at = payment.get("time")
if payment.get("extra"):
extra = json.loads(str(payment.get("extra")))
tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at")
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id
""", # noqa: S608
{
"tag": tag,
"created_at": created_at,
"checking_id": payment.get("checking_id"),
},
)
offset += limit
logger.info("Payments updated")
async def m028_update_settings(db: Connection):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS system_settings (
id TEXT PRIMARY KEY,
value TEXT,
tag TEXT NOT NULL DEFAULT 'core',
UNIQUE (id, tag)
);
"""
)
async def _insert_key_value(id_: str, value: Any):
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
""",
{"id": id_, "value": json.dumps(value), "tag": "core"},
)
row: dict = await db.fetchone("SELECT * FROM settings")
if row:
await _insert_key_value("super_user", row["super_user"])
editable_settings = json.loads(row["editable_settings"])
for key, value in editable_settings.items():
await _insert_key_value(key, value)
await db.execute("drop table settings")
async def m029_create_audit_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS audit (
component TEXT,
ip_address TEXT,
user_id TEXT,
path TEXT,
request_type TEXT,
request_method TEXT,
request_details TEXT,
response_code TEXT,
duration REAL NOT NULL,
delete_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m030_add_user_api_tokens_column(db: Connection):
await db.execute(
"""
ALTER TABLE accounts ADD COLUMN access_control_list TEXT
"""
)
async def m031_add_color_and_icon_to_wallets(db: Connection):
"""
Adds icon and color columns to wallets.
"""
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
async def m032_add_external_id_to_accounts(db: Connection):
"""
Adds external_id column to accounts.
Used for external account linking.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
-455
View File
@@ -1,455 +0,0 @@
import datetime
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from enum import Enum
from sqlite3 import Row
from typing import Callable, Dict, List, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from loguru import logger
from pydantic import BaseModel
from lnbits.db import Connection, FilterModel, FromRowModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
class BaseWallet(BaseModel):
id: str
name: str
adminkey: str
inkey: str
balance_msat: int
class Wallet(BaseWallet):
user: str
currency: Optional[str]
deleted: bool
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property
def balance(self) -> int:
return self.balance_msat // 1000
@property
def withdrawable_balance(self) -> int:
from .services import fee_reserve
return self.balance_msat - fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash)
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
class UserConfig(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider
class Account(FromRowModel):
id: str
is_super_user: Optional[bool] = False
is_admin: Optional[bool] = False
username: Optional[str] = None
email: Optional[str] = None
balance_msat: Optional[int] = 0
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
last_payment: Optional[datetime.datetime] = None
class AccountFilters(FilterModel):
__search_fields__ = ["id", "email", "username"]
__sort_fields__ = [
"balance_msat",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
]
id: str
last_payment: Optional[datetime.datetime] = None
transaction_count: Optional[int] = None
wallet_count: Optional[int] = None
username: Optional[str] = None
email: Optional[str] = None
class User(BaseModel):
id: str
email: Optional[str] = None
username: Optional[str] = None
extensions: List[str] = []
wallets: List[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
config: Optional[UserConfig] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property
def wallet_ids(self) -> List[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class CreateUser(BaseModel):
email: Optional[str] = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
config: Optional[UserConfig] = None
class UpdateUserPassword(BaseModel):
user_id: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class Payment(FromRowModel):
checking_id: str
pending: bool
amount: int
fee: int
memo: Optional[str]
time: int
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[float]
extra: Dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
@classmethod
def from_row(cls, row: Row):
return cls(
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
pending=row["pending"],
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
time=row["time"],
expiry=row["expiry"],
wallet_id=row["wallet"],
webhook=row["webhook"],
webhook_status=row["webhook_status"],
)
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < time.time() if self.expiry else False
@property
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("internal_")
async def update_status(
self,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> None:
from .crud import update_payment_details
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
conn=conn,
)
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
self.pending = pending
await update_payment_status(self.checking_id, pending)
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable:
return PaymentPendingStatus()
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: "
f"expired {expiration_date}"
)
await self.delete(conn)
# wait at least 15 minutes before deleting failed outgoing payments
elif self.is_out and status.failed:
if self.time + 900 < int(time.time()):
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete(conn)
else:
logger.warning(
f"Tried to delete outgoing payment {self.checking_id}: "
"skipping because it's not old enough"
)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' "
f"{self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_wallet_payment
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
checking_id: str
amount: int
fee: int
memo: Optional[str]
time: datetime.datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime.datetime]
extra: Dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
date: datetime.datetime
income: int
spending: int
balance: int
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: float
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class DecodePayment(BaseModel):
data: str
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
class CreateTopup(BaseModel):
id: str
amount: int
class CreateLnurlAuth(BaseModel):
callback: str
class CreateWallet(BaseModel):
name: Optional[str] = None
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: str
class BalanceDelta(BaseModel):
lnbits_balance_msats: int
node_balance_msats: int
@property
def delta_msats(self):
return self.node_balance_msats - self.lnbits_balance_msats
+104
View File
@@ -0,0 +1,104 @@
from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
from .misc import (
BalanceDelta,
Callback,
ConversionData,
CoreAppExtra,
DbVersion,
SimpleStatus,
)
from .payments import (
CancelInvoice,
CreateInvoice,
CreatePayment,
DecodePayment,
PayInvoice,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentExtra,
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentState,
PaymentWalletStats,
SettleInvoice,
)
from .tinyurl import TinyURL
from .users import (
AccessTokenPayload,
Account,
AccountFilters,
AccountOverview,
CreateUser,
LoginUsernamePassword,
LoginUsr,
RegisterUser,
ResetUserPassword,
UpdateBalance,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserAcls,
UserExtra,
)
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [
"AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
"AuditEntry",
"AuditFilters",
"BalanceDelta",
"BaseWallet",
"Callback",
"CancelInvoice",
"ConversionData",
"CoreAppExtra",
"CreateInvoice",
"CreateLnurlPayment",
"CreateLnurlWithdraw",
"CreatePayment",
"CreateUser",
"CreateWallet",
"CreateWebPushSubscription",
"DbVersion",
"DecodePayment",
"KeyType",
"LoginUsernamePassword",
"LoginUsr",
"PayInvoice",
"Payment",
"PaymentCountField",
"PaymentCountStat",
"PaymentDailyStats",
"PaymentExtra",
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
"PaymentWalletStats",
"PaymentsStatusCount",
"RegisterUser",
"ResetUserPassword",
"SettleInvoice",
"SimpleStatus",
"TinyURL",
"UpdateBalance",
"UpdateSuperuserPassword",
"UpdateUser",
"UpdateUserPassword",
"UpdateUserPubkey",
"User",
"UserAcls",
"UserExtra",
"Wallet",
"WalletTypeInfo",
"WebPushSubscription",
]
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.settings import settings
class AuditEntry(BaseModel):
component: str | None = None
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_type: str | None = None
request_method: str | None = None
request_details: str | None = None
response_code: str | None = None
duration: float
delete_at: datetime | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data):
super().__init__(**data)
retention_days = max(0, settings.lnbits_audit_retention_days) or 365
self.delete_at = self.created_at + timedelta(days=retention_days)
class AuditFilters(FilterModel):
__search_fields__ = [
"ip_address",
"user_id",
"path",
"request_method",
"response_code",
"component",
]
__sort_fields__ = [
"created_at",
"duration",
]
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_method: str | None = None
response_code: str | None = None
component: str | None = None
class AuditCountStat(BaseModel):
field: str = ""
total: float = 0
class AuditStats(BaseModel):
request_method: list[AuditCountStat] = []
response_code: list[AuditCountStat] = []
component: list[AuditCountStat] = []
long_duration: list[AuditCountStat] = []
+736
View File
@@ -0,0 +1,736 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import shutil
import zipfile
from pathlib import Path
from typing import Any
import httpx
from loguru import logger
from pydantic import BaseModel
from lnbits.helpers import (
download_url,
file_hash,
is_lnbits_version_ok,
version_parse,
)
from lnbits.settings import settings
class ExplicitRelease(BaseModel):
id: str
name: str
version: str
archive: str
hash: str
dependencies: list[str] = []
repo: str | None
icon: str | None
short_description: str | None
min_lnbits_version: str | None
max_lnbits_version: str | None
html_url: str | None # todo: release_url
warning: str | None
info_notification: str | None
critical_notification: str | None
details_link: str | None
pay_link: str | None
def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
class GitHubRelease(BaseModel):
id: str
organisation: str
repository: str
class Manifest(BaseModel):
featured: list[str] = []
extensions: list[ExplicitRelease] = []
repos: list[GitHubRelease] = []
class GitHubRepoRelease(BaseModel):
name: str
tag_name: str
zipball_url: str
html_url: str
def details_link(self, source_repo: str) -> str:
return f"https://raw.githubusercontent.com/{source_repo}/{self.tag_name}/config.json"
class GitHubRepo(BaseModel):
stargazers_count: str
html_url: str
default_branch: str
class ExtensionConfig(BaseModel):
name: str
short_description: str
tile: str = ""
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
) -> ExtensionConfig | None:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
class ReleasePaymentInfo(BaseModel):
amount: int | None = None
pay_link: str | None = None
payment_hash: str | None = None
payment_request: str | None = None
class PayToEnableInfo(BaseModel):
amount: int = 0
required: bool = False
wallet: str | None = None
class UserExtensionInfo(BaseModel):
paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: UserExtensionInfo | None = None
@property
def is_paid(self) -> bool:
if not self.extra:
return False
return self.extra.paid_to_enable is True
@classmethod
def from_row(cls, data: dict) -> UserExtension:
ext = UserExtension(**data)
ext.extra = (
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
if "_extra" in data
else None
)
return ext
class Extension(BaseModel):
code: str
is_valid: bool
name: str | None = None
short_description: str | None = None
tile: str | None = None
upgrade_hash: str | None = ""
@property
def module_name(self) -> str:
if self.is_upgrade_extension:
return f"{self.code}-{self.upgrade_hash}"
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.code}"
return self.code
@property
def is_upgrade_extension(self) -> bool:
return self.upgrade_hash != ""
@classmethod
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
return Extension(
code=ext_info.id,
is_valid=True,
name=ext_info.name,
short_description=ext_info.short_description,
tile=ext_info.icon,
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
)
class ExtensionRelease(BaseModel):
name: str
version: str
archive: str
source_repo: str
is_github_release: bool = False
hash: str | None = None
min_lnbits_version: str | None = None
max_lnbits_version: str | None = None
is_version_compatible: bool | None = True
html_url: str | None = None
description: str | None = None
warning: str | None = None
repo: str | None = None
icon: str | None = None
details_link: str | None = None
pay_link: str | None = None
cost_sats: int | None = None
paid_sats: int | None = 0
payment_hash: str | None = None
@property
def archive_url(self) -> str:
if not self.pay_link:
return self.archive
return (
f"{self.archive}?version=v{self.version}&payment_hash={self.payment_hash}"
)
async def check_payment_requirements(self):
if not self.pay_link:
return
payment_info = await self.fetch_release_payment_info()
self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info(
self, amount: int | None = None
) -> ReleasePaymentInfo | None:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
if not url:
raise ValueError("Missing URL for payment info.")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
@classmethod
def from_github_release(
cls, source_repo: str, r: GitHubRepoRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=r.name,
description=r.name,
version=r.tag_name,
archive=r.zipball_url,
source_repo=source_repo,
is_github_release=True,
details_link=r.details_link(source_repo),
repo=f"https://github.com/{source_repo}",
html_url=r.html_url,
)
@classmethod
def from_explicit_release(
cls, source_repo: str, e: ExplicitRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=e.name,
version=e.version,
archive=e.archive,
hash=e.hash,
source_repo=source_repo,
description=e.short_description,
min_lnbits_version=e.min_lnbits_version,
max_lnbits_version=e.max_lnbits_version,
is_version_compatible=e.is_version_compatible(),
warning=e.warning,
html_url=e.html_url,
details_link=e.details_link,
pay_link=e.pay_link,
repo=e.repo,
icon=e.icon,
)
@classmethod
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try:
github_releases = await cls.fetch_github_releases(org, repo)
return [
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases
]
except Exception as e:
logger.warning(e)
return []
@classmethod
async def fetch_github_releases(
cls, org: str, repo: str
) -> list[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
class ExtensionMeta(BaseModel):
installed_release: ExtensionRelease | None = None
latest_release: ExtensionRelease | None = None
pay_to_enable: PayToEnableInfo | None = None
payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = []
archive: str | None = None
featured: bool = False
class InstallableExtension(BaseModel):
id: str
name: str
version: str
active: bool | None = False
short_description: str | None = None
icon: str | None = None
stars: int = 0
meta: ExtensionMeta | None = None
@property
def hash(self) -> str:
if self.meta and self.meta.installed_release:
if self.meta.installed_release.hash:
return self.meta.installed_release.hash
m = hashlib.sha256()
m.update(f"{self.meta.installed_release.archive}".encode())
return m.hexdigest()
return "not-installed"
@property
def zip_path(self) -> Path:
extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
return Path(extensions_data_dir, f"{self.id}.zip")
@property
def ext_dir(self) -> Path:
return Path(settings.lnbits_extensions_path, "extensions", self.id)
@property
def ext_upgrade_dir(self) -> Path:
return Path(settings.lnbits_extensions_upgrade_path, f"{self.id}-{self.hash}")
@property
def module_name(self) -> str:
if self.ext_upgrade_dir.is_dir():
return f"{self.id}-{self.hash}"
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.id}"
return self.id
@property
def has_installed_version(self) -> bool:
if not self.ext_dir.is_dir():
return False
return Path(self.ext_dir, "config.json").is_file()
@property
def installed_version(self) -> str:
if self.meta and self.meta.installed_release:
return self.meta.installed_release.version
return ""
@property
def requires_payment(self) -> bool:
if not self.meta or not self.meta.pay_to_enable:
return False
return self.meta.pay_to_enable.required is True
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
if ext_zip_file.is_file():
os.remove(ext_zip_file)
try:
if not self.meta or not self.meta.installed_release:
raise ValueError("No installed release.")
self._restore_payment_info()
await asyncio.to_thread(
download_url, self.meta.installed_release.archive_url, ext_zip_file
)
self._remember_payment_info()
except Exception as exc:
logger.warning(exc)
raise AssertionError("Cannot fetch extension archive file") from exc
archive_hash = file_hash(ext_zip_file)
if (
self.meta
and self.meta.installed_release.hash
and self.meta.installed_release.hash != archive_hash
):
# remove downloaded archive
if ext_zip_file.is_file():
os.remove(ext_zip_file)
raise AssertionError("File hash missmatch. Will not install.")
def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
shutil.rmtree(tmp_dir, True)
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir)
generated_dir_name = os.listdir(tmp_dir)[0]
shutil.rmtree(self.ext_upgrade_dir, True)
shutil.copytree(
Path(tmp_dir, generated_dir_name),
Path(self.ext_upgrade_dir),
)
shutil.rmtree(tmp_dir, True)
# Pre-packed extensions can be upgraded
# Mark the extension as installed so we know it is not the pre-packed version
with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
config_json = json.load(json_file)
self.name = config_json.get("name")
self.short_description = config_json.get("short_description")
if (
self.meta
and self.meta.installed_release
and self.meta.installed_release.is_github_release
and config_json.get("tile")
):
self.icon = icon_to_github_url(
self.meta.installed_release.source_repo, config_json.get("tile")
)
shutil.rmtree(self.ext_dir, True)
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
def clean_extension_files(self):
# remove downloaded archive
if self.zip_path.is_file():
os.remove(self.zip_path)
# remove module from extensions
shutil.rmtree(self.ext_dir, True)
shutil.rmtree(self.ext_upgrade_dir, True)
def check_latest_version(self, release: ExtensionRelease | None):
if not release:
return
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
self.meta = meta
return
if version_parse(self.meta.latest_release.version) < version_parse(
release.version
):
self.meta.latest_release = release
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
(p for p in self.meta.payments if p.pay_link == pay_link),
None,
)
def _restore_payment_info(self):
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
or not self.meta.installed_release.payment_hash
):
return
payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
if payment_info:
self.meta.installed_release.payment_hash = payment_info.payment_hash
def _remember_payment_info(self):
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
):
return
payment_info = ReleasePaymentInfo(
amount=self.meta.installed_release.cost_sats,
pay_link=self.meta.installed_release.pay_link,
payment_hash=self.meta.installed_release.payment_hash,
)
self.meta.payments = [
p for p in self.meta.payments if p.pay_link != payment_info.pay_link
]
self.meta.payments.append(payment_info)
@classmethod
async def from_github_release(
cls, github_release: GitHubRelease
) -> InstallableExtension | None:
try:
repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository
)
source_repo = f"{github_release.organisation}/{github_release.repository}"
return InstallableExtension(
id=github_release.id,
name=config.name,
version=latest_release.tag_name,
short_description=config.short_description,
stars=int(repo.stargazers_count),
icon=icon_to_github_url(
source_repo,
config.tile,
),
meta=ExtensionMeta(
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
),
)
except Exception as e:
logger.warning(e)
return None
@classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
return InstallableExtension(
id=e.id,
name=e.name,
version=e.version,
short_description=e.short_description,
icon=e.icon,
meta=meta,
)
@classmethod
def from_ext_dir(cls, ext_id: str) -> InstallableExtension | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
version = config_json.get("version", "0.0")
return InstallableExtension(
id=ext_id,
name=config_json.get("name", ext_id),
active=True,
version=version,
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
meta=ExtensionMeta(
installed_release=ExtensionRelease(
name=ext_id,
version=version,
archive=f"{conf_path}",
source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"),
max_lnbits_version=config_json.get("max_lnbits_version"),
)
),
)
except Exception as e:
logger.warning(e)
return None
@classmethod
async def get_installable_extensions(
cls,
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
for url in settings.lnbits_extensions_manifests:
try:
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
ext = await InstallableExtension.from_github_release(r)
if not ext:
continue
existing_ext = next(
(ee for ee in extension_list if ee.id == r.id), None
)
if existing_ext and ext.meta:
existing_ext.check_latest_version(ext.meta.latest_release)
continue
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext]
for e in manifest.extensions:
release = ExtensionRelease.from_explicit_release(url, e)
existing_ext = next(
(ee for ee in extension_list if ee.id == e.id), None
)
if existing_ext:
existing_ext.check_latest_version(release)
continue
ext = InstallableExtension.from_explicit_release(e)
ext.check_latest_version(release)
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext]
except Exception as e:
logger.warning(f"Manifest {url} failed with '{e!s}'")
settings.lnbits_all_extensions_ids = {e.id for e in extension_list}
return extension_list
@classmethod
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = []
for url in settings.lnbits_extensions_manifests:
try:
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
if r.id != ext_id:
continue
repo_releases = await ExtensionRelease.get_github_releases(
r.organisation, r.repository
)
extension_releases += repo_releases
for e in manifest.extensions:
if e.id != ext_id:
continue
explicit_release = ExtensionRelease.from_explicit_release(url, e)
await explicit_release.check_payment_requirements()
extension_releases.append(explicit_release)
except Exception as e:
logger.warning(f"Manifest {url} failed with '{e!s}'")
return extension_releases
@classmethod
async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str
) -> ExtensionRelease | None:
all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
selected_release = [
r
for r in all_releases
if r.archive == archive
and r.source_repo == source_repo
and r.version == version
]
return selected_release[0] if len(selected_release) != 0 else None
@classmethod
async def fetch_github_repo_info(
cls, org: str, repository: str
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
class CreateExtension(BaseModel):
ext_id: str
archive: str
source_repo: str
version: str
cost_sats: int | None = 0
payment_hash: str | None = None
class ExtensionDetailsRequest(BaseModel):
ext_id: str
source_repo: str
version: str
async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
def icon_to_github_url(source_repo: str, path: str | None) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
+16
View File
@@ -0,0 +1,16 @@
from typing import Optional
from lnurl import Lnurl, LnurlPayResponse
from pydantic import BaseModel
class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse
amount: int
comment: Optional[str] = None
unit: Optional[str] = None
internal_memo: Optional[str] = None
class CreateLnurlWithdraw(BaseModel):
lnurl_w: Lnurl
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Callable
from pydantic import BaseModel
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class BalanceDelta(BaseModel):
lnbits_balance_sats: int
node_balance_sats: int
@property
def delta_sats(self) -> int:
return int(self.lnbits_balance_sats - self.node_balance_sats)
class SimpleStatus(BaseModel):
success: bool
message: str
class SimpleItem(BaseModel):
id: str
name: str
class DbVersion(BaseModel):
db: str
version: int
class Image(BaseModel):
filename: str
directory: str = "library"
+68
View File
@@ -0,0 +1,68 @@
from enum import Enum
from pydantic import BaseModel
from lnbits.core.models.users import UserNotifications
class NotificationType(Enum):
server_status = "server_status"
settings_update = "settings_update"
balance_update = "balance_update"
watchdog_check = "watchdog_check"
balance_delta = "balance_delta"
server_start_stop = "server_start_stop"
incoming_payment = "incoming_payment"
outgoing_payment = "outgoing_payment"
text_message = "text_message"
class NotificationMessage(BaseModel):
message_type: NotificationType
values: dict
user_notifications: UserNotifications | None = None
NOTIFICATION_TEMPLATES = {
"text_message": "{message}",
"server_status": """*SERVER STATUS*
*Up time*: `{up_time}`.
*Accounts*: `{accounts_count}`.
*Wallets*: `{wallets_count}`.
*In/Out payments*: `{in_payments_count}`/`{out_payments_count}`.
*Pending payments*: `{pending_payments_count}`.
*Failed payments*: `{failed_payments_count}`.
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.""",
"server_start_stop": """*SERVER*
{message}
*Time*: `{up_time}` seconds.
""",
"settings_update": """*SETTINGS UPDATED*
User: `{username}`.
""",
"balance_update": """*WALLET CREDIT/DEBIT*
Wallet `{wallet_name}` balance updated with `{amount}` sats.
*Current balance*: `{balance}` sats.
*Wallet ID*: `{wallet_id}`
""",
"watchdog_check": """*WATCHDOG BALANCE CHECK*
*Delta*: `{delta_sats}` sats.
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.
*Switching to Void Wallet*: `{switch_to_void_wallet}`.
""",
"balance_delta": """*BALANCE DELTA CHANGED*
*New delta*: `{delta_sats}` sats.
*Old delta*: `{old_delta_sats}` sats.
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.
""",
"outgoing_payment": """*PAYMENT SENT*
*Amount*: {fiat_value_fmt}`{amount_sats}`*sats*.
*Wallet*: `{wallet_name}` ({wallet_id}).""",
"incoming_payment": """*PAYMENT RECEIVED*
*Amount*: {fiat_value_fmt}`{amount_sats}`*sats*.
*Wallet*: `{wallet_name}` ({wallet_id}).
""",
}
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Literal
from fastapi import Query
from lnurl import LnurlWithdrawResponse
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class PaymentExtra(BaseModel):
comment: str | None = None
success_action: str | None = None
lnurl_response: str | None = None
class PayInvoice(BaseModel):
payment_request: str
description: str | None = None
max_sat: int | None = None
extra: dict | None = {}
class CreatePayment(BaseModel):
wallet_id: str
payment_hash: str
bolt11: str
amount_msat: int
memo: str
extra: dict | None = {}
preimage: str | None = None
expiry: datetime | None = None
webhook: str | None = None
fee: int = 0
class Payment(BaseModel):
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
bolt11: str
# payment_request: str | None
fiat_provider: str | None = None
status: str = PaymentState.PENDING
memo: str | None = None
expiry: datetime | None = None
webhook: str | None = None
webhook_status: str | None = None
preimage: str | None = None
tag: str | None = None
extension: str | None = None
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
extra: dict = {}
@property
def pending(self) -> bool:
return self.status == PaymentState.PENDING.value
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
@property
def is_internal(self) -> bool:
return self.checking_id.startswith("internal_") or self.checking_id.startswith(
"fiat_"
)
async def check_status(
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
if self.is_in and self.fiat_provider:
fiat_status = await self.check_fiat_status(
skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not self.is_internal:
return FiatPaymentPendingStatus()
if self.success:
return FiatPaymentSuccessStatus()
if self.failed:
return FiatPaymentFailedStatus()
if not self.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = self.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(self.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(self.checking_id)
return fiat_status
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
status: str | None
tag: str | None
checking_id: str | None
amount: int
fee: int
memo: str | None
time: datetime
preimage: str | None
payment_hash: str | None
wallet_id: str | None
class PaymentDataPoint(BaseModel):
date: datetime
count: int
max_amount: int
min_amount: int
average_amount: int
total_amount: int
max_fee: int
min_fee: int
average_fee: int
total_fee: int
PaymentCountField = Literal["status", "tag", "extension", "wallet_id"]
class PaymentCountStat(BaseModel):
field: str = ""
total: float = 0
class PaymentWalletStats(BaseModel):
wallet_id: str = ""
wallet_name: str = ""
user_id: str = ""
payments_count: int
balance: float = 0
class PaymentDailyStats(BaseModel):
date: datetime
balance: float = 0
balance_in: float | None = 0
balance_out: float | None = 0
payments_count: int = 0
count_in: int | None = 0
count_out: int | None = 0
fee: float = 0
class PaymentHistoryPoint(BaseModel):
date: datetime
income: int
spending: int
balance: int
class DecodePayment(BaseModel):
data: str
filter_fields: list[str] | None = []
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: str | None = Query(None, max_length=640)
description_hash: str | None = None
unhashed_description: str | None = None
payment_hash: str | None = Query(
None,
description="The payment hash of the hold invoice.",
min_length=64,
max_length=64,
)
expiry: int | None = None
extra: dict | None = None
webhook: str | None = None
bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
@validator("payment_hash")
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
class PaymentsStatusCount(BaseModel):
incoming: int = 0
outgoing: int = 0
failed: int = 0
pending: int = 0
class SettleInvoice(BaseModel):
preimage: str = Field(
...,
description="The preimage of the payment hash to settle the invoice.",
min_length=64,
max_length=64,
)
@validator("preimage")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class CancelInvoice(BaseModel):
payment_hash: str = Field(
...,
description="The payment hash of the invoice to cancel.",
min_length=64,
max_length=64,
)
@validator("payment_hash")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
@@ -1,5 +1,4 @@
"""Keycloak SSO Login Helper
"""
"""Keycloak SSO Login Helper"""
from typing import Optional
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: float
+328
View File
@@ -0,0 +1,328 @@
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw
from fastapi import Query
from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem
from lnbits.db import FilterModel
from lnbits.helpers import (
is_valid_email_address,
is_valid_external_id,
is_valid_pubkey,
is_valid_username,
)
from lnbits.settings import settings
from .wallets import Wallet
class UserNotifications(BaseModel):
nostr_identifier: str | None = None
telegram_chat_id: str | None = None
email_address: str | None = None
excluded_wallets: list[str] = []
outgoing_payments_sats: int = 0
incoming_payments_sats: int = 0
class UserExtra(BaseModel):
email_verified: bool | None = False
first_name: str | None = None
last_name: str | None = None
display_name: str | None = None
picture: str | None = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: str | None = "lnbits" # auth provider
# how many wallets are shown in the user interface
visible_wallet_count: int | None = 10
notifications: UserNotifications = UserNotifications()
class EndpointAccess(BaseModel):
path: str
name: str
read: bool = False
write: bool = False
def supports_method(self, method: str) -> bool:
# all http methods
if method in ["GET", "OPTIONS", "HEAD"]:
return self.read
if method in ["POST", "PUT", "PATCH", "DELETE"]:
return self.write
return False
class AccessControlList(BaseModel):
id: str
name: str
endpoints: list[EndpointAccess] = []
token_id_list: list[SimpleItem] = []
def get_endpoint(self, path: str) -> EndpointAccess | None:
for e in self.endpoints:
if e.path == path:
return e
return None
def get_token_by_id(self, token_id: str) -> SimpleItem | None:
for t in self.token_id_list:
if t.id == token_id:
return t
return None
def delete_token_by_id(self, token_id: str):
self.token_id_list = [t for t in self.token_id_list if t.id != token_id]
class UserAcls(BaseModel):
id: str
access_control_list: list[AccessControlList] = []
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def get_acl_by_id(self, acl_id: str) -> AccessControlList | None:
for acl in self.access_control_list:
if acl.id == acl_id:
return acl
return None
def delete_acl_by_id(self, acl_id: str):
self.access_control_list = [
acl for acl in self.access_control_list if acl.id != acl_id
]
def get_acl_by_token_id(self, token_id: str) -> AccessControlList | None:
for acl in self.access_control_list:
if acl.get_token_by_id(token_id):
return acl
return None
class Account(BaseModel):
id: str
external_id: str | None = None # for external account linking
username: str | None = None
password_hash: str | None = None
pubkey: str | None = None
email: str | None = None
extra: UserExtra = UserExtra()
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True)
fiat_providers: list[str] = Field(default=[], no_database=True)
def __init__(self, **data):
super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
salt = gensalt()
hashed_pw = hashpw(password.encode(), salt)
if not hashed_pw:
raise ValueError("Password hashing failed.")
self.password_hash = hashed_pw.decode()
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
return checkpw(password.encode(), self.password_hash.encode())
def validate_fields(self):
if self.username and not is_valid_username(self.username):
raise ValueError("Invalid username.")
if self.email and not is_valid_email_address(self.email):
raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid pubkey.")
if self.external_id and not is_valid_external_id(self.external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
class AccountOverview(Account):
transaction_count: int | None = 0
wallet_count: int | None = 0
balance_msat: int | None = 0
last_payment: datetime | None = None
class AccountFilters(FilterModel):
__search_fields__ = [
"user",
"email",
"username",
"pubkey",
"external_id",
"wallet_id",
]
__sort_fields__ = [
"balance_msat",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
]
email: str | None = None
user: str | None = None
username: str | None = None
pubkey: str | None = None
external_id: str | None = None
wallet_id: str | None = None
class User(BaseModel):
id: str
created_at: datetime
updated_at: datetime
email: str | None = None
username: str | None = None
pubkey: str | None = None
external_id: str | None = None # for external account linking
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
fiat_providers: list[str] = []
has_password: bool = False
extra: UserExtra = UserExtra()
@property
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Wallet | None:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class RegisterUser(BaseModel):
email: str | None = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class CreateUser(BaseModel):
id: str | None = Query(default=None)
email: str | None = Query(default=None)
username: str | None = Query(default=None, min_length=2, max_length=20)
password: str | None = Query(default=None, min_length=8, max_length=50)
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
pubkey: str = Query(default=None, max_length=64)
external_id: str = Query(default=None, max_length=256)
extensions: list[str] | None = None
extra: UserExtra | None = None
class UpdateUser(BaseModel):
user_id: str
username: str | None = Query(default=..., min_length=2, max_length=20)
extra: UserExtra | None = None
class UpdateUserPassword(BaseModel):
user_id: str
password_old: str | None = None
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
username: str = Query(default=..., min_length=2, max_length=20)
class UpdateUserPubkey(BaseModel):
user_id: str
pubkey: str = Query(default=..., max_length=64)
class ResetUserPassword(BaseModel):
reset_key: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class AccessTokenPayload(BaseModel):
sub: str
usr: str | None = None
email: str | None = None
auth_time: int | None = 0
api_token_id: str | None = None
class UpdateBalance(BaseModel):
id: str
amount: int
class ApiTokenRequest(BaseModel):
acl_id: str
token_name: str
password: str
expiration_time_minutes: int
class ApiTokenResponse(BaseModel):
id: str
api_token: str
class UpdateAccessControlList(AccessControlList):
password: str
class DeleteAccessControlList(BaseModel):
id: str
password: str
class DeleteTokenRequest(BaseModel):
id: str
acl_id: str
password: str
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from ecdsa import SECP256k1, SigningKey
from lnurl import encode as lnurl_encode
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.settings import settings
class BaseWallet(BaseModel):
id: str
name: str
adminkey: str
inkey: str
balance_msat: int
class WalletExtra(BaseModel):
icon: str = "flash_on"
color: str = "primary"
pinned: bool = False
class Wallet(BaseModel):
id: str
user: str
name: str
adminkey: str
inkey: str
deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: str | None = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
@property
def balance(self) -> int:
return int(self.balance_msat // 1000)
@property
def withdrawable_balance(self) -> int:
return self.balance_msat - settings.fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
class CreateWallet(BaseModel):
name: str | None = None
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
id: str | None
name: str | None
currency: str | None
+15
View File
@@ -0,0 +1,15 @@
from datetime import datetime
from pydantic import BaseModel
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: datetime
-799
View File
@@ -1,799 +0,0 @@
import asyncio
import datetime
import json
import time
from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict
from urllib.parse import parse_qs, urlparse
import httpx
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import (
EditableSettings,
SuperSettings,
readonly_variables,
send_admin_user_to_saas,
settings,
)
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source, set_funding_source
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
)
from .crud import (
check_internal,
check_internal_pending,
create_account,
create_admin_settings,
create_payment,
create_wallet,
delete_wallet_payment,
get_account,
get_payments,
get_standalone_payment,
get_super_settings,
get_total_balance,
get_wallet,
get_wallet_payment,
update_admin_settings,
update_payment_details,
update_payment_status,
update_super_user,
)
from .helpers import to_valid_user_id
from .models import BalanceDelta, Payment, UserConfig, Wallet
class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class InvoiceError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
async def calculate_fiat_amounts(
amount: float,
wallet_id: str,
currency: Optional[str] = None,
extra: Optional[Dict] = None,
conn: Optional[Connection] = None,
) -> Tuple[int, Optional[Dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
extra = extra or {}
if currency != wallet_currency:
extra["fiat_currency"] = currency
extra["fiat_amount"] = round(amount, ndigits=3)
extra["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
if wallet_currency:
if wallet_currency == currency:
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
extra = extra or {}
extra["wallet_fiat_currency"] = wallet_currency
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
)
return amount_sat, extra
async def create_invoice(
*,
wallet_id: str,
amount: float,
currency: Optional[str] = "sat",
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Tuple[str, str]:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts(
amount, wallet_id, currency=currency, extra=extra, conn=conn
)
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceError(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
)
(
ok,
checking_id,
payment_request,
error_message,
) = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if not ok or not payment_request or not checking_id:
raise InvoiceError(
error_message or "unexpected backend error.", status="pending"
)
invoice = bolt11_decode(payment_request)
amount_msat = 1000 * amount_sat
await create_payment(
wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_msat,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
conn=conn,
)
return invoice.payment_hash, payment_request
async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[Dict] = None,
description: str = "",
conn: Optional[Connection] = None,
) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve
fee. We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend. If the payment is
successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process
will regularly check for the payment.
"""
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise PaymentError("Amountless invoices not supported.", status="failed")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise PaymentError("Amount in invoice is too high.", status="failed")
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
async with db.reuse_conn(conn) if conn else db.connect() as conn:
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
_, extra = await calculate_fiat_amounts(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
)
# put all parameters that don't change here
class PaymentKwargs(TypedDict):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
expiry: Optional[datetime.datetime]
extra: Optional[Dict]
payment_kwargs: PaymentKwargs = PaymentKwargs(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
# we check if an internal invoice exists that has already been paid
# (not pending anymore)
if not await check_internal_pending(invoice.payment_hash, conn=conn):
raise PaymentError("Internal invoice already paid.", status="failed")
# check_internal() returns the checking_id of the invoice we're waiting for
# (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_checking_id, incoming=True, conn=conn
)
assert internal_invoice is not None
if (
internal_invoice.amount != invoice.amount_msat
or internal_invoice.bolt11 != payment_request.lower()
):
raise PaymentError("Invalid invoice.", status="failed")
logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet
new_payment = await create_payment(
checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat),
pending=False,
conn=conn,
**payment_kwargs,
)
else:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=False
)
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment.", status="failed") from exc
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched"
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if (
not internal_checking_id
and wallet.balance_msat > -fee_reserve_total_msat
):
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
raise PaymentError("Insufficient balance.", status="failed")
if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id, pending=False, conn=conn
)
await send_payment_notification(wallet, new_payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
await internal_invoice_queue.put(internal_checking_id)
else:
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice
funding_source = get_funding_source()
payment: PaymentResponse = await funding_source.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got:"
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
logger.debug(f"backend: pay_invoice response {payment}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
pending=payment.ok is not True,
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
),
preimage=payment.preimage,
new_checking_id=payment.checking_id,
conn=conn,
)
wallet = await get_wallet(wallet_id, conn=conn)
updated = await get_wallet_payment(
wallet_id, payment.checking_id, conn=conn
)
if wallet and updated:
await send_payment_notification(wallet, updated)
logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed
logger.warning("backend sent payment failure")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
raise PaymentError(
f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {temp_id}"
)
# credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat:
new_payment = await create_payment(
wallet_id=settings.lnbits_service_fee_wallet,
fee=0,
amount=abs(service_fee_msat),
memo="Service fee",
checking_id="service_fee" + temp_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
pending=False,
)
return invoice.payment_hash
async def check_wallet_limits(wallet_id, conn, amount_msat):
await check_time_limit_between_transactions(conn, wallet_id)
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
async def check_time_limit_between_transactions(conn, wallet_id):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
total = 0
for pay in payments:
total += pay.amount
total = total - amount_msat
if limit * 1000 + total < 0:
raise ValueError(
"Daily withdrawal limit of "
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
+ " sats reached."
)
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[Dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist"
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentPendingStatus()
if not payment.pending:
# note: before, we still checked the status of the payment again
return PaymentSuccessStatus(fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status()
return status
# WARN: this same value must be used for balance check and passed to
# funding_source.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
if internal:
return 0
reserve_min = settings.lnbits_reserve_fee_min
reserve_percent = settings.lnbits_reserve_fee_percent
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
def service_fee(amount_msat: int, internal: bool = False) -> int:
service_fee_percent = settings.lnbits_service_fee
fee_max = settings.lnbits_service_fee_max * 1000
if settings.lnbits_service_fee_wallet:
if internal and settings.lnbits_service_fee_ignore_internal:
return 0
fee_percentage = int(amount_msat / 100 * service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
else:
return 0
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocket_updater(
wallet.id,
json.dumps(
{
"wallet_balance": wallet.balance,
"payment": payment.dict(),
}
),
)
async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
)
async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(checking_id=checking_id, pending=False, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(checking_id)
async def check_admin_settings():
if settings.super_user:
settings.super_user = to_valid_user_id(settings.super_user).hex
if settings.lnbits_admin_ui:
settings_db = await get_super_settings()
if not settings_db:
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.config and account.config.provider == "env":
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
continue
try:
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
if "super_user" in sets_dict:
settings.super_user = sets_dict["super_user"]
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
account = None
if super_user:
account = await get_account(super_user)
if not account:
account = await create_account(
user_id=super_user, user_config=UserConfig(provider="env")
)
if not account.wallets or len(account.wallets) == 0:
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id, data):
return await websocket_manager.send_data(f"{data}", item_id)
async def switch_to_voidwallet() -> None:
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
set_funding_source("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> BalanceDelta:
funding_source = get_funding_source()
status = await funding_source.status()
lnbits_balance = await get_total_balance()
return BalanceDelta(
lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat,
)
+72
View File
@@ -0,0 +1,72 @@
from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
)
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl
from .notifications import enqueue_admin_notification, send_payment_notification
from .payments import (
calculate_fiat_amounts,
cancel_hold_invoice,
check_transaction_status,
check_wallet_limits,
create_fiat_invoice,
create_invoice,
create_payment_request,
create_wallet_invoice,
fee_reserve,
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
service_fee,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
update_wallet_balance,
)
from .settings import (
check_webpush_settings,
update_cached_settings,
)
from .users import (
check_admin_settings,
create_user_account,
create_user_account_no_ckeck,
update_user_account,
update_user_extensions,
)
from .websockets import websocket_manager, websocket_updater
__all__ = [
"calculate_fiat_amounts",
"cancel_hold_invoice",
"check_admin_settings",
"check_transaction_status",
"check_wallet_limits",
"check_webpush_settings",
"create_fiat_invoice",
"create_invoice",
"create_payment_request",
"create_user_account",
"create_user_account_no_ckeck",
"create_wallet_invoice",
"enqueue_admin_notification",
"fee_reserve",
"fee_reserve_total",
"fetch_lnurl_pay_request",
"get_balance_delta",
"get_payments_daily_stats",
"get_pr_from_lnurl",
"pay_invoice",
"send_payment_notification",
"service_fee",
"settle_hold_invoice",
"switch_to_voidwallet",
"update_cached_settings",
"update_pending_payment",
"update_pending_payments",
"update_user_account",
"update_user_extensions",
"update_wallet_balance",
"websocket_manager",
"websocket_updater",
]
+179
View File
@@ -0,0 +1,179 @@
import asyncio
import importlib
from typing import Optional
from loguru import logger
from lnbits.core import core_app_extra
from lnbits.core.crud import (
create_installed_extension,
delete_installed_extension,
get_db_version,
get_installed_extension,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
get_installed_extensions,
update_installed_extension,
)
from lnbits.core.helpers import migrate_extension_database
from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta()
if (
ext_info.meta.installed_release
and not ext_info.meta.installed_release.is_version_compatible
):
raise ValueError("Incompatible extension version")
installed_ext = await get_installed_extension(ext_info.id)
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
await ext_info.download_archive()
ext_info.extract_archive()
db_version = await get_db_version(ext_info.id)
await migrate_extension_database(ext_info, db_version)
# if the extensions does not exist in the installed extensions table, create it
# if it does exist, it will be activated later in the code
if not installed_ext:
await create_installed_extension(ext_info)
else:
await update_installed_extension(ext_info)
extension = Extension.from_installable_ext(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(ext_info.id)
await start_extension_background_work(ext_info.id)
return extension
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
settings.deactivate_extension_paths(ext_id)
extension = await get_installed_extension(ext_id)
if extension:
extension.clean_extension_files()
await delete_installed_extension(ext_id=ext_id)
async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
async def stop_extension_background_work(ext_id: str) -> bool:
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension must expose a `myextension_stop()` function if it is starting tasks.
"""
upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
stop_fn_name = f"{ext_id}_stop"
if not hasattr(old_module, stop_fn_name):
raise ValueError(f"No stop function found for '{ext.module_name}'.")
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
if asyncio.iscoroutinefunction(stop_fn):
await stop_fn()
else:
stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
async def start_extension_background_work(ext_id: str) -> bool:
"""
Start background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension CAN expose a `myextension_start()` function if it is starting tasks.
Extension MUST expose a `myextension_stop()` in that case.
"""
upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
try:
logger.info(f"Starting background work for extension '{ext.module_name}'.")
new_module = importlib.import_module(ext.module_name)
start_fn_name = f"{ext_id}_start"
# start function is optional, return False if not found
if not hasattr(new_module, start_fn_name):
return False
start_fn = getattr(new_module, start_fn_name)
if start_fn:
if asyncio.iscoroutinefunction(start_fn):
await start_fn()
else:
start_fn()
logger.info(f"Started background work for extension '{ext.module_name}'.")
return True
except Exception as ex:
logger.warning(f"Failed to start background work for '{ext.module_name}'.")
logger.warning(ex)
return False
async def get_valid_extensions(
include_deactivated: Optional[bool] = True,
) -> list[Extension]:
installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
async def get_valid_extension(
ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
ext = await get_installed_extension(ext_id)
if not ext:
return None
if include_deactivated:
return Extension.from_installable_ext(ext)
if settings.lnbits_extensions_deactivate_all:
return None
return Extension.from_installable_ext(ext)
+194
View File
@@ -0,0 +1,194 @@
import hashlib
import hmac
import time
from typing import Optional
from loguru import logger
from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment, get_standalone_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection
from lnbits.fiat import get_fiat_provider
from lnbits.settings import settings
async def handle_fiat_payment_confirmation(
payment: Payment, conn: Optional[Connection] = None
):
try:
await _credit_fiat_service_fee_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
try:
await _debit_fiat_service_faucet_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
async def _credit_fiat_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
if payment.fee == 0:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_fee_wallet_id:
return
memo = (
f"Service fee for fiat payment of "
f"{abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_fee_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(payment.fee),
memo=memo,
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def _debit_fiat_service_faucet_wallet(
payment: Payment, conn: Optional[Connection] = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_faucet_wallet_id:
return
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
if not faucet_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' faucet wallet not found."
)
memo = (
f"Faucet payment of {abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_faucet_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=-abs(payment.amount),
memo=memo,
extra=payment.extra,
)
await create_payment(
checking_id=f"internal_fiat_{fiat_provider_name}_"
f"faucet_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def handle_stripe_event(event: dict):
event_id = event.get("id")
event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if not payment_hash:
logger.warning("Stripe event does not contain a payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
def check_stripe_signature(
payload: bytes,
sig_header: Optional[str],
secret: Optional[str],
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Stripe-Signature header is missing.")
raise ValueError("Stripe-Signature header is missing.")
if not secret:
logger.warning("Stripe webhook signing secret is not set.")
raise ValueError("Stripe webhook cannot be verified.")
# Split the Stripe-Signature header
items = dict(i.split("=") for i in sig_header.split(","))
timestamp = int(items["t"])
signature = items["v1"]
# Check timestamp tolerance
if abs(time.time() - timestamp) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
logger.debug(
f"Current time: {time.time()}, "
f"Timestamp: {timestamp}, "
f"Tolerance: {tolerance_seconds} seconds"
)
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = f"{timestamp}.{payload.decode()}"
# Compute HMAC SHA256 using the webhook secret
computed_signature = hmac.new(
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
).hexdigest()
# Compare signatures using constant time comparison
if hmac.compare_digest(computed_signature, signature) is not True:
logger.warning("Stripe signature verification failed.")
raise ValueError("Stripe signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
success=False,
message=f"Cconnection test failed: {status.error_message}",
)
return SimpleStatus(
success=True,
message="Connection test successful." f" Balance: {status.balance}.",
)
+83
View File
@@ -0,0 +1,83 @@
from loguru import logger
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.settings import settings
from lnbits.wallets import get_funding_source, set_funding_source
from ..crud import get_total_balance
from ..models import BalanceDelta
async def switch_to_voidwallet() -> None:
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
set_funding_source("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> BalanceDelta:
funding_source = get_funding_source()
status = await funding_source.status()
lnbits_balance = await get_total_balance()
return BalanceDelta(
lnbits_balance_sats=int(lnbits_balance) // 1000,
node_balance_sats=status.balance_msat // 1000,
)
async def check_server_balance_against_node():
"""
Watchdog will check lnbits balance and nodebalance
and will switch to VoidWallet if the watchdog delta is reached.
"""
if (
not settings.lnbits_watchdog_switch_to_voidwallet
and not settings.lnbits_notification_watchdog
):
return
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
status = await get_balance_delta()
if status.delta_sats < settings.lnbits_watchdog_delta:
return
use_voidwallet = settings.lnbits_watchdog_switch_to_voidwallet
logger.warning(
f"Balance delta reached: {status.delta_sats} sats."
f" Switch to void wallet: {use_voidwallet}."
)
enqueue_admin_notification(
NotificationType.watchdog_check,
{
"delta_sats": status.delta_sats,
"lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats,
"switch_to_void_wallet": use_voidwallet,
},
)
if use_voidwallet:
logger.error(f"Switching to VoidWallet. Delta: {status.delta_sats} sats.")
await switch_to_voidwallet()
async def check_balance_delta_changed():
status = await get_balance_delta()
if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats
return
if status.delta_sats != settings.latest_balance_delta_sats:
enqueue_admin_notification(
NotificationType.balance_delta,
{
"delta_sats": status.delta_sats,
"old_delta_sats": settings.latest_balance_delta_sats,
"lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats,
},
)
settings.latest_balance_delta_sats = status.delta_sats
+53
View File
@@ -0,0 +1,53 @@
from lnurl import (
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
execute_pay_request,
handle,
)
from lnbits.core.models import CreateLnurlPayment
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
async def get_pr_from_lnurl(lnurl: str, amount_msat: int) -> str:
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
res2 = await execute_pay_request(
res,
msat=str(amount_msat),
user_agent=settings.user_agent,
timeout=10,
)
if not isinstance(res, LnurlPayActionResponse):
raise LnurlResponseException(
"Invalid LNURL pay response. Expected LnurlPayActionResponse."
)
return res2.pr
async def fetch_lnurl_pay_request(data: CreateLnurlPayment) -> LnurlPayActionResponse:
"""
Pay an LNURL payment request.
raises `LnurlResponseException` if pay request fails
"""
if data.unit and data.unit != "sat":
# shift to float with 2 decimal places
amount = round(data.amount / 1000, 2)
amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
amount_msat *= 1000
else:
amount_msat = data.amount
return await execute_pay_request(
data.res,
msat=str(amount_msat),
user_agent=settings.user_agent,
timeout=10,
)
+72
View File
@@ -0,0 +1,72 @@
import asyncio
import httpx
from loguru import logger
from pynostr.encrypted_dm import EncryptedDirectMessage
from websocket import WebSocket, create_connection
from lnbits.core.helpers import is_valid_url
from lnbits.utils.nostr import (
validate_identifier,
validate_pub_key,
)
async def send_nostr_dm(
from_private_key_hex: str,
to_pubkey_hex: str,
message: str,
relays: list[str],
) -> dict:
dm = EncryptedDirectMessage()
dm.encrypt(
private_key_hex=from_private_key_hex,
recipient_pubkey=to_pubkey_hex,
cleartext_content=message,
)
dm_event = dm.to_event()
dm_event.sign(private_key_hex=from_private_key_hex)
notification = dm_event.to_message()
ws_connections: list[WebSocket] = []
for relay in relays:
try:
ws = create_connection(relay, timeout=2)
ws.send(notification)
ws_connections.append(ws)
except Exception as e:
logger.warning(f"Error sending notification to relay {relay}: {e}")
await asyncio.sleep(1)
for ws in ws_connections:
try:
ws.close()
except Exception as e:
logger.debug(f"Failed to close websocket connection: {e}")
return dm_event.to_dict()
async def fetch_nip5_details(identifier: str) -> tuple[str, list[str]]:
identifier, domain = identifier.split("@")
if not identifier or not domain:
raise ValueError("Invalid NIP5 identifier")
if not is_valid_url(f"https://{domain}"):
raise ValueError("Invalid NIP5 domain")
validate_identifier(identifier)
url = f"https://{domain}/.well-known/nostr.json?name={identifier}"
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
if "names" not in data or identifier not in data["names"]:
raise ValueError("NIP5 not name found")
pubkey = data["names"][identifier]
validate_pub_key(pubkey)
relays = data["relays"].get(pubkey, []) if "relays" in data else []
return pubkey, relays
+410
View File
@@ -0,0 +1,410 @@
import asyncio
import json
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from http import HTTPStatus
from typing import Optional
import httpx
from loguru import logger
from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import (
delete_webpush_subscriptions,
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.crud.users import get_user
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
NotificationMessage,
NotificationType,
)
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url, is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
def enqueue_admin_notification(message_type: NotificationType, values: dict) -> None:
if not _is_message_type_enabled(message_type):
return
try:
notifications_queue.put_nowait(
NotificationMessage(message_type=message_type, values=values)
)
except Exception as e:
logger.error(f"Error enqueuing notification: {e}")
def enqueue_user_notification(
message_type: NotificationType, values: dict, user_notifications: UserNotifications
) -> None:
try:
notifications_queue.put_nowait(
NotificationMessage(
message_type=message_type,
values=values,
user_notifications=user_notifications,
)
)
except Exception as e:
logger.error(f"Error enqueuing notification: {e}")
async def process_next_notification() -> None:
notification_message = await notifications_queue.get()
message_type, text = _notification_message_to_text(notification_message)
user_notifications = notification_message.user_notifications
if user_notifications:
await send_user_notification(user_notifications, text, message_type)
else:
await send_admin_notification(text, message_type)
async def send_admin_notification(
message: str,
message_type: Optional[str] = None,
) -> None:
return await send_notification(
settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails,
message,
message_type,
)
async def send_user_notification(
user_notifications: UserNotifications,
message: str,
message_type: Optional[str] = None,
) -> None:
email_address = (
[user_notifications.email_address] if user_notifications.email_address else []
)
nostr_identifiers = (
[user_notifications.nostr_identifier]
if user_notifications.nostr_identifier
else []
)
return await send_notification(
user_notifications.telegram_chat_id,
nostr_identifiers,
email_address,
message,
message_type,
)
async def send_notification(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str,
message_type: Optional[str] = None,
) -> None:
try:
if telegram_chat_id and settings.is_telegram_notifications_configured():
await send_telegram_notification(telegram_chat_id, message)
logger.debug(f"Sent telegram notification: {message_type}")
except Exception as e:
logger.error(f"Error sending telegram notification {message_type}: {e}")
try:
if nostr_identifiers and settings.is_nostr_notifications_configured():
await send_nostr_notifications(nostr_identifiers, message)
logger.debug(f"Sent nostr notification: {message_type}")
except Exception as e:
logger.error(f"Error sending nostr notification {message_type}: {e}")
try:
if email_addresses and settings.lnbits_email_notifications_enabled:
await send_email_notification(email_addresses, message)
logger.debug(f"Sent email notification: {message_type}")
except Exception as e:
logger.error(f"Error sending email notification {message_type}: {e}")
async def send_nostr_notifications(identifiers: list[str], message: str) -> list[str]:
success_sent: list[str] = []
for identifier in identifiers:
try:
await send_nostr_notification(identifier, message)
success_sent.append(identifier)
except Exception as e:
logger.warning(f"Error notifying identifier {identifier}: {e}")
return success_sent
async def send_nostr_notification(identifier: str, message: str):
nip5 = await fetch_nip5_details(identifier)
user_pubkey = nip5[0]
relays = nip5[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
async def send_telegram_notification(chat_id: str, message: str) -> dict:
return await send_telegram_message(
settings.lnbits_telegram_notifications_access_token,
chat_id,
message,
)
async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {"chat_id": chat_id, "text": message, "parse_mode": "markdown"}
async with httpx.AsyncClient() as client:
response = await client.post(url, data=payload)
response.raise_for_status()
return response.json()
async def send_email_notification(
to_emails: list[str], message: str, subject: str = "LNbits Notification"
) -> dict:
if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"}
try:
await send_email(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
to_emails,
subject,
message,
)
return {"status": "ok"}
except Exception as e:
logger.error(f"Error sending email notification: {e}")
return {"status": "error", "message": str(e)}
async def send_email(
server: str,
port: int,
username: str,
password: str,
from_email: str,
to_emails: list[str],
subject: str,
message: str,
) -> bool:
if not is_valid_email_address(from_email):
raise ValueError(f"Invalid from email address: {from_email}")
if len(to_emails) == 0:
raise ValueError("No email addresses provided")
for email in to_emails:
if not is_valid_email_address(email):
raise ValueError(f"Invalid email address: {email}")
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
username = username if len(username) > 0 else from_email
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
return True
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
"""
logger.debug("sending webhook", payment.webhook)
if not payment.webhook:
return await mark_webhook_sent(payment.payment_hash, "-1")
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(payment.webhook)
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, str(exc.response.status_code))
logger.warning(
f"webhook returned a bad status_code: {exc.response.status_code} "
f"while requesting {exc.request.url!r}."
)
except httpx.RequestError:
await mark_webhook_sent(payment.payment_hash, "-1")
logger.warning(f"Could not send webhook to {payment.webhook}")
async def send_payment_notification(wallet: Wallet, payment: Payment):
try:
await send_ws_payment_notification(wallet, payment)
except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}")
try:
await send_chat_payment_notification(wallet, payment)
except Exception as e:
logger.error(f"Error sending chat payment notification {e!s}")
try:
await send_payment_push_notification(wallet, payment)
except Exception as e:
logger.error(f"Error sending push payment notification {e!s}")
try:
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
except Exception as e:
logger.error(f"Error dispatching webhook: {e!s}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send_data(payment.json(), wallet.inkey)
# TODO: figure out why we send the balance with the payment here.
# cleaner would be to have a separate message for the balance
# and send it with the id of the wallet so wallets can subscribe to it
payment_notification = json.dumps(
{
"wallet_balance": wallet.balance,
# use pydantic json serialization to get the correct datetime format
"payment": json.loads(payment.json()),
},
)
await websocket_manager.send_data(payment_notification, wallet.inkey)
await websocket_manager.send_data(payment_notification, wallet.adminkey)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending, "status": payment.status}),
payment.payment_hash,
)
async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
amount_sats = abs(payment.sat)
values: dict = {
"wallet_id": wallet.id,
"wallet_name": wallet.name,
"amount_sats": amount_sats,
"fiat_value_fmt": "",
}
if payment.extra.get("wallet_fiat_currency", None):
amount_fiat = payment.extra.get("wallet_fiat_amount", None)
currency = payment.extra.get("wallet_fiat_currency", None)
values["fiat_value_fmt"] = f"`{amount_fiat}`*{currency}* / "
if payment.is_out:
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
enqueue_admin_notification(NotificationType.outgoing_payment, values)
elif amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_admin_notification(NotificationType.incoming_payment, values)
user = await get_user(wallet.user)
user_notifications = user.extra.notifications if user else None
if user_notifications and wallet.id not in user_notifications.excluded_wallets:
out_limit = user_notifications.outgoing_payments_sats
in_limit = user_notifications.incoming_payments_sats
if payment.is_out and (amount_sats >= out_limit):
enqueue_user_notification(
NotificationType.outgoing_payment, values, user_notifications
)
elif amount_sats >= in_limit:
enqueue_user_notification(
NotificationType.incoming_payment, values, user_notifications
)
async def send_payment_push_notification(wallet: Wallet, payment: Payment):
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
amount = int(payment.amount / 1000)
title = f"LNbits: {wallet.name}"
body = f"You just received {amount} sat{'s'[:amount^1]}!"
if payment.memo:
body += f"\r\n{payment.memo}"
for subscription in subscriptions:
# todo: review permissions when user-id-only not allowed
# todo: replace all this logic with websockets?
url = f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
await send_push_notification(subscription, title, body, url)
async def send_push_notification(subscription, title, body, url=""):
vapid = Vapid()
try:
logger.debug("sending push notification")
webpush(
json.loads(subscription.data),
json.dumps({"title": title, "body": body, "url": url}),
(
vapid.from_pem(bytes(settings.lnbits_webpush_privkey, "utf-8"))
if settings.lnbits_webpush_privkey
else None
),
{"aud": "", "sub": "mailto:alan@lnbits.com"},
)
except WebPushException as e:
if e.response and e.response.status_code == HTTPStatus.GONE:
# cleanup unsubscribed or expired push subscriptions
await delete_webpush_subscriptions(subscription.endpoint)
else:
logger.error(
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)
def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
from cryptography.hazmat.primitives import serialization
from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.db import dict_to_model
from lnbits.settings import (
EditableSettings,
UpdateSettings,
readonly_variables,
settings,
)
from ..crud import update_admin_settings
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
if not vapid.public_key:
raise ValueError("VAPID public key does not exist")
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
if settings.lnbits_admin_ui:
await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def dict_to_settings(sets_dict: dict) -> UpdateSettings:
return dict_to_model(sets_dict, UpdateSettings)
def update_cached_settings(sets_dict: dict):
editable_settings = dict_to_settings(sets_dict)
for key in sets_dict.keys():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
continue
try:
value = getattr(editable_settings, key)
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}.")
if "super_user" in sets_dict:
settings.super_user = sets_dict["super_user"]
+187
View File
@@ -0,0 +1,187 @@
from pathlib import Path
from typing import Optional
from uuid import uuid4
from loguru import logger
from lnbits.core.models.extensions import UserExtension
from lnbits.settings import (
EditableSettings,
SuperSettings,
settings,
)
from ..crud import (
create_account,
create_admin_settings,
create_user_extension,
create_wallet,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_super_settings,
get_user_extensions,
get_user_from_account,
update_account,
update_super_user,
update_user_extension,
)
from ..helpers import to_valid_user_id
from ..models import (
Account,
User,
UserExtra,
)
from .settings import update_cached_settings
async def create_user_account(
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
return await create_user_account_no_ckeck(account, wallet_name)
async def create_user_account_no_ckeck(
account: Optional[Account] = None,
wallet_name: Optional[str] = None,
default_exts: Optional[list[str]] = None,
) -> User:
if account:
account.validate_fields()
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email):
raise ValueError("Email already exists.")
if account.pubkey and await get_account_by_pubkey(account.pubkey):
raise ValueError("Pubkey already exists.")
if not account.id:
account.id = uuid4().hex
account = await create_account(account)
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
for ext_id in user_extensions:
try:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await create_user_extension(user_ext)
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account)
if not user:
raise ValueError("Cannot find user for account.")
return user
async def update_user_account(account: Account) -> Account:
account.validate_fields()
existing_account = await get_account(account.id)
if not existing_account:
raise ValueError("User does not exist.")
account.password_hash = existing_account.password_hash
if existing_account.username and not account.username:
raise ValueError("Cannot remove username.")
if account.username:
existing_account = await get_account_by_username(account.username)
if existing_account and existing_account.id != account.id:
raise ValueError("Username already exists.")
elif existing_account.username:
raise ValueError("Cannot remove username.")
if account.email:
existing_account = await get_account_by_email(account.email)
if existing_account and existing_account.id != account.id:
raise ValueError("Email already exists.")
if account.pubkey:
existing_account = await get_account_by_pubkey(account.pubkey)
if existing_account and existing_account.id != account.id:
raise ValueError("Pubkey already exists.")
return await update_account(account)
async def update_user_extensions(user_id: str, extensions: list[str]):
user_extensions = await get_user_extensions(user_id)
for user_ext in user_extensions:
if user_ext.active:
if user_ext.extension not in extensions:
user_ext.active = False
await update_user_extension(user_ext)
else:
if user_ext.extension in extensions:
user_ext.active = True
await update_user_extension(user_ext)
user_extension_ids = [ue.extension for ue in user_extensions]
for ext in extensions:
if ext in user_extension_ids:
continue
user_extension = UserExtension(user=user_id, extension=ext, active=True)
await create_user_extension(user_extension)
async def check_admin_settings():
if settings.super_user:
settings.super_user = to_valid_user_id(settings.super_user).hex
if settings.lnbits_admin_ui:
settings_db = await get_super_settings()
if not settings_db:
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
account = await get_account(settings.super_user)
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
account = None
if super_user:
account = await get_account(super_user)
if not account:
account_id = super_user or uuid4().hex
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
)
await create_account(account)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
+27
View File
@@ -0,0 +1,27 @@
from fastapi import WebSocket
from loguru import logger
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(data, item_id)
+133 -116
View File
@@ -1,159 +1,176 @@
import asyncio
from typing import Dict
import traceback
from collections.abc import Coroutine
from typing import Callable
import httpx
from loguru import logger
from lnbits.core.crud import (
create_audit_entry,
get_wallet,
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.models import Payment
from lnbits.core.services import (
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.models.audit import AuditEntry
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
get_balance_delta,
send_payment_notification,
switch_to_voidwallet,
)
from lnbits.settings import get_funding_source, settings
from lnbits.tasks import send_push_notification
from lnbits.core.services.notifications import (
enqueue_admin_notification,
process_next_notification,
send_payment_notification,
)
from lnbits.db import Filters
from lnbits.settings import settings
from lnbits.tasks import create_unique_task
from lnbits.utils.exchange_rates import btc_rates
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
async def killswitch_task():
"""
killswitch will check lnbits-status repository for a signal from
LNbits and will switch to VoidWallet if the killswitch is triggered.
"""
async def run_by_the_minute_tasks() -> None:
minute_counter = 0
while settings.lnbits_running:
funding_source = get_funding_source()
if (
settings.lnbits_killswitch
and funding_source.__class__.__name__ != "VoidWallet"
):
with httpx.Client() as client:
try:
r = client.get(settings.lnbits_status_manifest, timeout=4)
r.raise_for_status()
if r.status_code == 200:
ks = r.json().get("killswitch")
if ks and ks == 1:
logger.error(
"Switching to VoidWallet. Killswitch triggered."
)
await switch_to_voidwallet()
except (httpx.RequestError, httpx.HTTPStatusError):
logger.error(
"Cannot fetch lnbits status manifest."
f" {settings.lnbits_status_manifest}"
)
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
status_minutes = settings.lnbits_notification_server_status_hours * 60
async def watchdog_task():
"""
Registers a watchdog which will check lnbits balance and nodebalance
and will switch to VoidWallet if the watchdog delta is reached.
"""
while settings.lnbits_running:
funding_source = get_funding_source()
if (
settings.lnbits_watchdog
and funding_source.__class__.__name__ != "VoidWallet"
):
if settings.notification_balance_delta_changed:
try:
balance = await get_balance_delta()
delta = balance.delta_msats
logger.debug(f"Running watchdog task. current delta: {delta}")
if delta + settings.lnbits_watchdog_delta <= 0:
logger.error(f"Switching to VoidWallet. current delta: {delta}")
await switch_to_voidwallet()
except Exception as e:
logger.error("Error in watchdog task", e)
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
# runs by default every minute, the delta should not change that often
await check_balance_delta_changed()
except Exception as ex:
logger.error(ex)
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
try:
await check_server_balance_against_node()
except Exception as ex:
logger.error(ex)
if minute_counter % status_minutes == 0:
try:
await _notify_server_status()
except Exception as ex:
logger.error(ex)
if minute_counter % 60 == 0:
try:
# initialize the list of all extensions
await InstallableExtension.get_installable_extensions()
except Exception as ex:
logger.error(ex)
minute_counter += 1
await asyncio.sleep(60)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
async def _notify_server_status() -> None:
accounts = await get_accounts(filters=Filters(limit=0))
wallets_count = await get_wallets_count()
payments = await get_payments_status_count()
status = await get_balance_delta()
values = {
"up_time": settings.lnbits_server_up_time,
"accounts_count": accounts.total,
"wallets_count": wallets_count,
"in_payments_count": payments.incoming,
"out_payments_count": payments.outgoing,
"pending_payments_count": payments.pending,
"failed_payments_count": payments.failed,
"delta_sats": status.delta_sats,
"lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats,
}
enqueue_admin_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
"""
This worker dispatches events to all extensions and dispatches webhooks.
"""
while settings.lnbits_running:
payment = await invoice_paid_queue.get()
logger.trace("received invoice paid event")
# dispatch api_invoice_listeners
await dispatch_api_invoice_listeners(payment)
# payment notification
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
# dispatch webhook
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
# dispatch push notification
await send_payment_push_notification(payment)
async def dispatch_api_invoice_listeners(payment: Payment):
async def wait_for_audit_data() -> None:
"""
Emits events to invoice listener subscribed from the API.
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
for chan_name, send_channel in api_invoice_listeners.items():
while settings.lnbits_running:
data = await audit_queue.get()
try:
logger.debug(f"api invoice listener: sending paid event to {chan_name}")
send_channel.put_nowait(payment)
except asyncio.QueueFull:
logger.error(
f"api invoice listener: QueueFull, removing {send_channel}:{chan_name}"
)
api_invoice_listeners.pop(chan_name)
await create_audit_entry(data)
except Exception as ex:
logger.warning(ex)
await asyncio.sleep(3)
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
"""
logger.debug("sending webhook", payment.webhook)
async def wait_notification_messages() -> None:
if not payment.webhook:
return await mark_webhook_sent(payment.payment_hash, -1)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict()
while settings.lnbits_running:
try:
r = await client.post(payment.webhook, json=data, timeout=40)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, r.status_code)
except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
logger.warning(
f"webhook returned a bad status_code: {exc.response.status_code} "
f"while requesting {exc.request.url!r}."
)
except httpx.RequestError:
await mark_webhook_sent(payment.payment_hash, -1)
logger.warning(f"Could not send webhook to {payment.webhook}")
await process_next_notification()
except Exception as ex:
logger.warning("Payment notification error", ex)
await asyncio.sleep(3)
async def send_payment_push_notification(payment: Payment):
wallet = await get_wallet(payment.wallet_id)
async def purge_audit_data() -> None:
"""
Remove audit entries which have passed their retention period.
"""
while settings.lnbits_running:
try:
await delete_expired_audit_entries()
except Exception as ex:
logger.warning(ex)
if wallet:
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
# clean every hour
await asyncio.sleep(60 * 60)
amount = int(payment.amount / 1000)
title = f"LNbits: {wallet.name}"
body = f"You just received {amount} sat{'s'[:amount^1]}!"
async def collect_exchange_rates_data() -> None:
"""
Collect exchange rates data. Used for monitoring only.
"""
while settings.lnbits_running:
currency = settings.lnbits_default_accounting_currency or "USD"
max_history_size = settings.lnbits_exchange_history_size
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
if payment.memo:
body += f"\r\n{payment.memo}"
if sleep_time > 0:
try:
rates = await btc_rates(currency)
if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
else:
sleep_time = 60
await asyncio.sleep(sleep_time)
for subscription in subscriptions:
# todo: review permissions when user-id-only not allowed
# todo: replace all this logic with websockets?
url = (
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
await send_push_notification(subscription, title, body, url)
def _create_unique_task(name: str, func: Callable):
async def _to_coro(func: Callable[[], Coroutine]) -> Coroutine:
return await func()
try:
create_unique_task(name, _to_coro(func))
except Exception as e:
logger.error(f"Error in {name} task", e)
logger.error(traceback.format_exc())
+228
View File
@@ -0,0 +1,228 @@
<q-tab-panel name="audit">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Audit</h6>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label v-text="$t('enable_audit')"></q-item-label>
<q-item-label caption v-text="$t('audit_desc')"></q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-6 col-sm-12">
<q-input
filled
v-model="formData.lnbits_audit_retention_days"
type="number"
label="Retention days"
hint="Number of days to keep the audit entry."
>
</q-input>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<div class="row">
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_request_body"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label v-text="$t('audit_record_req')"></q-item-label>
<q-item-label caption>
<span v-text="$t('audit_record_warning')"></span>
<br />
<ul>
<li><span v-text="$t('audit_record_req_warning_1')"></span></li>
<li><span v-text="$t('audit_record_req_warning_2')"></span></li>
</ul>
<br />
<span v-text="$t('audit_record_use')"></span>
</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_ip_address"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('audit_ip')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('audit_ip_desc')"></span>
</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_path_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('audit_path_params')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('recommended')"></span>
</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_query_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('audit_query_params')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('recommended')"></span>
</q-item-label>
</q-item-section>
</q-item>
</div>
</div>
<q-separator class="q-mb-xl q-mt-sm"></q-separator>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_http_methods')"></span></p>
<q-select
filled
v-model="formData.lnbits_audit_http_methods"
multiple
:hint="$t('audit_http_methods_hint')"
:label="$t('audit_http_methods_label')"
:options="['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']"
></q-select>
</div>
<div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_resp_codes')"></span></p>
<q-input
class="q-mb-md"
filled
v-model="formAddIncludeResponseCode"
@keydown.enter="addIncludeResponseCode"
type="text"
:label="$t('audit_resp_codes_label')"
:hint="$t('audit_resp_codes_hint')"
>
<q-btn @click="addIncludeResponseCode" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="code in formData.lnbits_audit_http_response_codes"
:key="code"
removable
@remove="removeIncludeResponseCode(code)"
color="primary"
text-color="white"
:label="code"
>
</q-chip>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_paths')"></span></p>
<q-input
class="q-mb-md"
filled
v-model="formAddIncludePath"
@keydown.enter="addIncludePath"
type="text"
:label="$t('audit_paths_label')"
:hint="$t('audit_paths_hint')"
>
<q-btn @click="addIncludePath" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="path in formData.lnbits_audit_include_paths"
:key="path"
removable
@remove="removeIncludePath(path)"
color="primary"
text-color="white"
:label="path"
>
</q-chip>
</div>
<br />
</div>
<div class="col-md-6 col-sm-12">
<p>
<span v-text="$t('audit_paths_exclude')"></span>
</p>
<q-input
class="q-mb-md"
filled
v-model="formAddExcludePath"
@keydown.enter="addExcludePath"
type="text"
:label="$t('audit_paths_exclude_label')"
:hint="$t('audit_paths_exclude_hint')"
>
<q-btn @click="addExcludePath" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="path in formData.lnbits_audit_exclude_paths"
:key="path"
removable
@remove="removeExcludePath(path)"
color="primary"
text-color="white"
:label="path"
>
</q-chip>
</div>
<br />
<br />
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,197 @@
<q-tab-panel name="exchange_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<div class="row">
<div class="col-md-8 col-sm-12">
<div class="q-pa-sm">
<canvas
style="
width: 100% !important;
height: auto !important;
min-height: 350px;
max-height: 50vh;
"
ref="exchangeRatesChart"
></canvas>
</div>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
type="number"
label="Refresh Interval (seconds)"
hint="How often should the exchange rates be fetched. Set to zero to disable."
>
</q-input>
<q-input
filled
v-model="formData.lnbits_exchange_history_size"
type="number"
label="History Size"
hint="How many data points should be kept in memory."
>
</q-input>
<ul>
<li>
<code>Refresh Interval</code> and <code> History Size </code>are for
historical purposes only.
</li>
<li>These two settings do not affect the live price computation.</li>
<li>
Chart currency:
<strong
><span
v-text="formData.lnbits_default_accounting_currency || 'USD'"
></span
></strong>
</li>
</ul>
</div>
</div>
<div class="row q-mt-md">
<div class="col-6">
<q-btn
@click="addExchangeProvider()"
label="Add Exchange Provider"
color="primary"
class="q-mb-md"
>
</q-btn>
</div>
<div class="col-6">
<q-btn
@click="getDefaultSetting('lnbits_exchange_rate_providers')"
flat
:label="$t('reset_defaults')"
color="primary"
class="float-right"
>
</q-btn>
</div>
</div>
<q-table
row-key="name"
:rows="formData.lnbits_exchange_rate_providers"
:columns="exchangesTable.columns"
v-model:pagination="exchangesTable.pagination"
>
<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">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
@click="removeExchangeProvider(props.row)"
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
>
</q-btn>
</q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.name"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td full-width>
<q-input
dense
filled
v-model="props.row.api_url"
@update:model-value="touchSettings()"
type="text"
>
</q-input
></q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.path"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td>
<q-select
filled
dense
v-model="props.row.exclude_to"
@update:model-value="touchSettings()"
multiple
:options="{{ currencies | safe }}"
></q-select>
</q-td>
<q-td>
<q-btn
@click="showTickerConversionDialog(props.row)"
round
icon="add"
size="sm"
color="gray"
class="q-ml-xs"
>
</q-btn>
<q-chip
v-for="ticker, index in props.row.ticker_conversion"
:key="ticker"
removable
dense
filled
@remove="removeExchangeTickerConversion(props.row, ticker)"
color="primary"
text-color="white"
:label="ticker"
class="ellipsis"
>
</q-chip>
</q-td>
</q-tr>
</template>
</q-table>
<ul>
<li>
<code>API URL</code> and <code>JSON Path</code> fields can use the
<code>{to}</code> and <code>{TO}</code> placeholders for the code of the
currency
</li>
<li>
<code>{TO}</code> is the uppercase code and <code>{to}</code> is the
lowercase code
</li>
</ul>
<q-separator class="q-ma-md"></q-separator>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_rate_cache_seconds"
type="number"
label="Exchange rate cache (seconds)"
hint="For how many seconds should the exchange rate be cached."
>
</q-input>
</div>
<div class="col-md-8 col-sm-12"></div>
</div>
</q-tab-panel>
@@ -0,0 +1,111 @@
<q-tab-panel name="extensions">
<q-card-section class="q-pa-none">
<div>
<h6 class="q-my-none">
<span v-text="$t('extensions')"></span>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12 q-mb-md">
<p>
<span v-text="$t('extension_sources')"></span>
</p>
<q-input
class="q-mb-md"
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
:label="$t('ext_sources_label')"
:hint="$t('ext_sources_hint')"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="manifestUrl"></span
></q-chip>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>
<span v-text="$t('admin_extensions')"></span>
</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
:label="$t('admin_extensions_label')"
:hint="$t('admin_extensions_hint')"
:options="g.extensions"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('user_default_extensions')"></span>
</p>
<q-select
filled
v-model="formData.lnbits_user_default_extensions"
multiple
:label="$t('user_default_extensions_label')"
:hint="$t('user_default_extensions_hint')"
:options="g.extensions"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('miscellaneous')"></span>
</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
<span v-text="$t('misc_disable_extensions')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('misc_disable_extensions_label')"></span>
</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
<span v-text="$t('misc_hide_api')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('misc_hide_api_label')"></span>
</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,287 @@
<q-tab-panel name="fiat_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('fiat_providers')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<div class="row">
<div class="col">
<q-list bordered class="rounded-borders">
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Stripe </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.stripe_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.stripe_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_api_secret_key"
:label="$t('secret_key')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.stripe_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('stripe')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
<span v-text="$t('webhook_stripe_description')"></span>
</q-card-section>
<q-card-section>
<q-input
filled
class="q-mt-md"
type="text"
disable
v-model="formData.stripe_payment_webhook_url"
:label="$t('webhook_url')"
:hint="$t('webhook_url_hint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.stripe_webhook_signing_secret"
:label="$t('signing_secret')"
:hint="$t('signing_secret_hint')"
></q-input>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li><code>checkout.session.async_payment_failed</code></li>
<li><code>checkout.session.async_payment_succeeded</code></li>
<li><code>checkout.session.completed</code></li>
<li><code>checkout.session.expired</code></li>
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_fee_percent"
@update:model-value="touchSettings()"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_fee_sats"
@update:model-value="touchSettings()"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.stripe_limits.service_fee_wallet_id"
@update:model-value="touchSettings()"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_min_amount_sats"
@update:model-value="touchSettings()"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.stripe_limits.service_max_amount_sats"
@update:model-value="touchSettings()"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.stripe_limits.service_faucet_wallet_id"
@update:model-value="touchSettings()"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
<q-item>
<q-item-section>
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
<q-item-label caption>
<ul>
<li>
<span
v-text="$t('faucest_wallet_desc_1', {provider: 'stripe'})"
></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_2', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_3')"></span>
</li>
<li>
<span
v-text="$t('faucest_wallet_desc_4', {provider: 'stripe'})"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_5')"></span>
</li>
</ul>
<br />
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddStripeUser"
@keydown.enter="addAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint_feature', {feature: 'Stripe'})"
>
<q-btn
@click="addStripeAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.stripe_limits.allowed_users"
@update:model-value="touchSettings()"
:key="user"
removable
@remove="removeStripeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</q-expansion-item>
<q-separator />
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar>
<img
:src="'{{ static_url_for('static', 'images/square_logo.png') }}'"
/>
</q-avatar>
</q-item-section>
<q-item-section> Square </q-item-section>
<q-item-section side>
<div class="row items-center">Disabled</div>
</q-item-section>
</template>
<q-card>
<q-card-section> Coming Soon </q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</div>
</div>
</q-tab-panel>
+140 -40
View File
@@ -1,88 +1,104 @@
<q-tab-panel name="funding">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">Wallets Management</h6>
<h6 class="q-my-none">
<span v-text="$t('wallets_management')"></span>
</h6>
<br />
<div>
<div class="row">
<div class="col">
<p>Funding Source Info</p>
<p>
<span v-text="$t('funding_source_info')"></span>
</p>
<ul>
<li
v-text="'Funding Source: '+ settings.lnbits_backend_wallet_class"
v-text="$t('funding_source', {wallet_class: settings.lnbits_backend_wallet_class})"
></li>
<li
v-text="'Node Balance: ' + (auditData.node_balance_msats /
1000).toLocaleString() + ' sats'"
v-text="$t('node_balance', {balance: (auditData.node_balance_sats || 0).toLocaleString()})"
></li>
<li
v-text="'LNbits Balance: ' + (auditData.lnbits_balance_msats /
1000).toLocaleString() + ' sats'"
v-text="$t('lnbits_balance', {balance: (auditData.lnbits_balance_sats || 0).toLocaleString()})"
></li>
<li
v-text="'Reserve Percent: ' + (auditData.node_balance_msats /
auditData.lnbits_balance_msats * 100).toFixed(2) + ' %'"
v-text="$t('funding_reserve_percent', {
percent: auditData.lnbits_balance_sats > 0
? (auditData.node_balance_sats / auditData.lnbits_balance_sats * 100).toFixed(2)
: 100
})"
></li>
</ul>
<br />
</div>
<div class="col">
{% if LNBITS_NODE_UI_AVAILABLE %}
<p>Node Management</p>
<p><span v-text="$t('node_management')"></span></p>
<q-toggle
label="Node UI"
:label="$t('toggle_node_ui')"
v-model="formData.lnbits_node_ui"
></q-toggle>
<q-toggle
v-if="formData.lnbits_node_ui"
label="Public node UI"
:label="$t('toggle_public_node_ui')"
v-model="formData.lnbits_public_node_ui"
></q-toggle>
<br />
<q-toggle
v-if="formData.lnbits_node_ui"
label="Transactions Tab (Disable on large CLN nodes)"
:label="$t('toggle_transactions_node_ui')"
v-model="formData.lnbits_node_ui_transactions"
></q-toggle>
{% else %}
<p>Node Management not supported by active funding source</p>
<p><span v-text="$t('node_management_not_supported')"></span></p>
{% endif %}
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<p>Invoice Expiry</p>
<div class="col-12 col-md-3">
<p><span v-text="$t('fee_reserve')"></span></p>
<q-input
type="number"
filled
v-model="formData.lnbits_reserve_fee_min"
:label="$t('fee_reserve_msats')"
>
</q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('fee_reserve_percent')"></span></p>
<q-input
type="number"
filled
name="lnbits_reserve_fee_percent"
v-model="formData.lnbits_reserve_fee_percent"
:label="$t('reserve_fee_in_percent')"
step="0.1"
></q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('invoice_expiry')"></span></p>
<q-input
filled
v-model.number="formData.lightning_invoice_expiry"
type="number"
label="Invoice expiry (seconds)"
:label="$t('invoice_expiry_label')"
mask="#######"
>
</q-input>
</div>
<div class="col-12 col-md-8">
<p>Fee reserve</p>
<div class="row q-col-gutter-md">
<div class="col-6">
<q-input
type="number"
filled
v-model="formData.lnbits_reserve_fee_min"
label="Reserve fee in msats"
>
</q-input>
</div>
<div class="col-6">
<q-input
type="number"
filled
name="lnbits_reserve_fee_percent"
v-model="formData.lnbits_reserve_fee_percent"
label="Reserve fee in percent"
step="0.1"
></q-input>
</div>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('payment_wait_time')"></span></p>
<q-input
type="number"
filled
name="lnbits_funding_source_pay_invoice_wait_seconds"
v-model="formData.lnbits_funding_source_pay_invoice_wait_seconds"
:label="$t('payment_wait_time')"
:hint="$t('payment_wait_time_desc')"
step="1"
min="0"
></q-input>
</div>
</div>
<div v-if="isSuperUser">
@@ -91,6 +107,90 @@
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
</div>
<q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog_switch_to_voidwallet"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_watchdog_limit')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_watchdog_limit_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval_minutes"
type="number"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,67 @@
<q-tab-panel name="library">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('image_library')"></span>
</h6>
<q-btn
color="primary"
label="Add Image"
@click="$refs.imageInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="imageInput"
accept="image/png, image/jpeg, image/gif"
style="display: none"
@change="onImageInput"
/>
</q-card-section>
<div class="row q-col-gutter-sm q-pa-sm">
<div
v-for="image in library_images"
:key="image.filename"
class="col-6 col-sm-4 col-md-3 col-lg-2"
style="max-width: 200px"
>
<q-card class="q-mb-sm">
<q-img :src="image.url" style="height: 150px" />
<q-card-section
class="q-pt-md q-pb-md row items-center justify-between"
>
<small
><div
class="text-caption ellipsis"
style="max-width: 100px"
:title="image.filename"
v-text="image.filename"
></div
></small>
<q-btn
dense
flat
size="sm"
icon="content_copy"
@click="copyText(image.url)"
:title="$t('copy')"
><q-tooltip>Copy image link</q-tooltip></q-btn
>
<q-btn
dense
flat
size="sm"
icon="delete"
color="negative"
@click="deleteImage(image.filename)"
:title="$t('delete')"
><q-tooltip>Delete image</q-tooltip></q-btn
>
</q-card-section>
</q-card>
</div>
</div>
<div v-if="library_images.length === 0" class="q-pa-xl">
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
</div>
</q-tab-panel>
@@ -0,0 +1,505 @@
<q-tab-panel name="notifications">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('notifications_configure')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<q-separator class="q-mt-md q-mb-sm"></q-separator>
<div class="row q-col-gutter-md">
<div class="col-sm-12 col-md-6">
<strong v-text="$t('notifications_nostr_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_nostr')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_nostr_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_nostr_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_nostr_private_key')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_nostr_private_key_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_nostr_notifications_private_key"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_nostr_identifiers')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_nostr_identifiers_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="nostrNotificationIdentifier"
@keydown.enter="addNostrNotificationIdentifier"
>
<q-btn
@click="addNostrNotificationIdentifier()"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="identifier in formData.lnbits_nostr_notifications_identifiers"
:key="identifier"
removable
@remove="removeNostrNotificationIdentifier(identifier)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="identifier"></span
></q-chip>
</div>
</q-item-section>
</q-item>
</div>
<div class="col-sm-12 col-md-6">
<strong v-text="$t('notifications_telegram_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_telegram')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_telegram_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_telegram_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_telegram_access_token')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_telegram_access_token_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_telegram_notifications_access_token"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('notifications_chat_id')"></q-item-label>
<q-item-label
caption
v-text="$t('notifications_chat_id_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_telegram_notifications_chat_id"
/>
</q-item-section>
</q-item>
</div>
<div class="col-sm-12">
<q-separator></q-separator>
</div>
<div class="col-12">
<strong v-text="$t('notifications_email_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_email')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_email_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_email_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<div v-if="formData.lnbits_email_notifications_enabled" class="row">
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_email"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_username')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_username_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_username"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_password')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_password_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_password"
/>
</q-item-section>
</q-item>
<q-item>
<q-btn
@click="sendTestEmail()"
:label="$t('notifications_send_test_email')"
color="primary"
class="q-mt-md"
></q-btn>
</q-item>
</div>
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_to_emails')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_to_emails_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="emailNotificationAddress"
@keydown.enter="addEmailNotificationAddress"
>
<q-btn
@click="addEmailNotificationAddress()"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="to_email in formData.lnbits_email_notifications_to_emails"
:key="to_email"
removable
@remove="removeEmailNotificationAddress(to_email)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="to_email"></span
></q-chip>
</div>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_server_port')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_server_port_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_port"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_server')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_server_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_server"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
</div>
<q-separator> </q-separator>
<h6 class="q-mb-sm">
<span v-text="$t('notifications')"></span>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_settings_update')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_settings_update_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_settings_update"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_credit_debit')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_credit_debit_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_credit_debit"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_server_start_stop')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_server_start_stop_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_server_start_stop"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_balance_delta_changed')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_balance_delta_changed_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.notification_balance_delta_changed"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_watchdog_limit')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_watchdog_limit_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_server_status')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_server_status_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-input
class="flow-right"
type="number"
min="0"
filled
v-model="formData.lnbits_notification_server_status_hours"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_incoming_payment')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_incoming_payment_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-input
class="flow-right"
type="number"
min="0"
filled
v-model="formData.lnbits_notification_incoming_payment_amount_sats"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_outgoing_payment')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_outgoing_payment_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-input
class="flow-right"
type="number"
min="0"
filled
v-model="formData.lnbits_notification_outgoing_payment_amount_sats"
/>
</q-item-section>
</q-item>
</div>
</div>
</q-card-section>
</q-tab-panel>
+116 -181
View File
@@ -1,29 +1,77 @@
<q-tab-panel name="security">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Authentication</h6>
<h6 class="q-my-none"><span v-text="$t('server_management')"></span></h6>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<div class="col-md-6">
<p><span v-text="$t('base_url')"></span></p>
<q-input
filled
v-model.number="formData.lnbits_baseurl"
:label="$t('base_url_label')"
></q-input>
<br />
</div>
</div>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('authentication')"></span>
</h6>
<div class="row q-col-gutter-sm">
<div class="col-12 col-sm-6">
<q-input
filled
v-model="formData.auth_token_expire_minutes"
type="number"
label="Token expire minutes"
hint="Time in minutes until the token expires"
:label="$t('auth_token_expiry_label')"
:hint="$t('auth_token_expiry_hint')"
>
</q-input>
</div>
<div class="col-md-6 col-sm-12 q-pr-sm">
<div class="col-12 col-sm-6">
<q-select
filled
v-model="formData.auth_allowed_methods"
multiple
hint="Allowed authorization methods"
label="Select authorization methods"
:hint="$t('auth_allowed_methods_hint')"
:label="$t('auth_allowed_methods_label')"
:options="formData.auth_all_methods"
></q-select>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('nostr-auth-nip98')"
class="q-pl-xl"
>
<strong class="q-my-none q-mb-sm">Nostr Auth</strong>
<div class="row">
<div class="col-12">
<q-input
class="q-mb-sm"
filled
v-model="nostrAcceptedUrl"
@keydown.enter="addNostrUrl"
type="text"
:label="$t('auth_nostr_label')"
:hint="$t('auth_nostr_hint')"
>
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
</q-input>
</div>
<div class="col-12">
<q-chip
v-for="url in formData.nostr_absolute_request_urls"
:key="url"
removable
@remove="removeNostrUrl(url)"
color="primary"
text-color="white"
:label="url"
class="ellipsis"
></q-chip>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('google-auth')"
class="q-pl-xl"
@@ -35,8 +83,8 @@
<q-input
filled
v-model="formData.google_client_id"
label="Google Client ID"
hint="Make sure thant the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token"
:label="$t('auth_google_ci_label')"
:hint="$t('auth_google_ci_hint')"
>
</q-input>
</div>
@@ -45,7 +93,7 @@
filled
v-model="formData.google_client_secret"
type="password"
label="Google Client Secret"
:label="$t('auth_google_cs_label')"
>
</q-input>
</div>
@@ -62,8 +110,8 @@
<q-input
filled
v-model="formData.github_client_id"
label="GitHub Client ID"
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/github/token"
:label="$t('auth_gh_client_id_label')"
:hint="$t('auth_gh_client_id_hint')"
>
</q-input>
</div>
@@ -72,7 +120,7 @@
filled
v-model="formData.github_client_secret"
type="password"
label="GitHub Client Secret"
:label="$t('auth_gh_client_secret_label')"
>
</q-input>
</div>
@@ -84,21 +132,21 @@
>
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
<div class="row">
<div class="col-md-4 col-sm-12 q-pr-sm">
<div class="row q-col-gutter-sm q-col-gutter-y-md">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_discovery_url"
label="Keycloak Discovey URL"
:label="$t('auth_keycloak_label')"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12 q-pr-sm">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_id"
label="Keycloak Client ID"
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token"
:label="$t('auth_keycloak_ci_label')"
:hint="$t('auth_keycloak_ci_hint')"
>
</q-input>
</div>
@@ -107,7 +155,23 @@
filled
v-model="formData.keycloak_client_secret"
type="password"
label="Keycloak Client Secret"
:label="$t('auth_keycloak_cs_label')"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_custom_org"
:label="$t('auth_keycloak_custom_org_label')"
>
</q-input>
</div>
<div class="col-md-8 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_custom_icon"
:label="$t('auth_keycloak_custom_icon_label')"
>
</q-input>
</div>
@@ -173,7 +237,8 @@
@remove="removeBlockedIPs(blocked_ip)"
color="primary"
text-color="white"
v-text="blocked_ip"
:label="blocked_ip"
class="ellipsis"
></q-chip>
</div>
<br />
@@ -202,7 +267,8 @@
@remove="removeAllowedIPs(allowed_ip)"
color="primary"
text-color="white"
v-text="allowed_ip"
:label="allowed_ip"
class="ellipsis"
></q-chip>
</div>
<br />
@@ -233,172 +299,41 @@
</div>
<div class="col-12 col-md-12">
<p v-text="$t('wallet_limiter')"></p>
<p v-text="$t('callback_url_rules')"></p>
<div class="row q-col-gutter-md">
<div class="col-4">
<div class="col-12">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_max_balance"
:label="$t('wallet_max_ballance')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
:label="$t('wallet_limit_max_withdraw_per_day')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
:label="$t('wallet_limit_secs_between_trans')"
></q-input>
v-model="formCallbackUrlRule"
@keydown.enter="addCallbackUrlRule"
type="text"
:label="$t('enter_callback_url_rule')"
:hint="$t('callback_url_rule_hint')"
>
<q-btn
@click="addCallbackUrlRule"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="rule in formData.lnbits_callback_url_rules"
:key="rule"
removable
@remove="removeCallbackUrlRule(rule)"
color="primary"
text-color="white"
:label="rule"
class="ellipsis"
></q-chip>
</div>
<br />
</div>
</div>
</div>
</div>
</div>
<br />
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_notifications_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notifications"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
<p
v-if="!formData.lnbits_notifications"
v-text="$t('notifications_disabled')"
></p>
<div v-if="formData.lnbits_notifications">
{% include "admin/_tab_security_notifications.html" %}
</div>
<br />
<div>
<p v-text="$t('notification_source')"></p>
<q-input
filled
v-model="formData.lnbits_status_manifest"
type="text"
:label="$t('notification_source_label')"
/>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p v-text="$t('killswitch')"></p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_killswitch_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_killswitch"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('killswitch_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_killswitch_interval"
type="number"
/>
</q-item-section>
</q-item>
<br />
<p v-text="$t('watchdog')"></p>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval"
type="number"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -1,77 +0,0 @@
<q-banner v-if="updateAvailable" class="bg-primary text-white">
<q-icon size="28px" name="update"></q-icon>
<span v-text="$t('update_available', {version: statusData.version})"></span>
<template v-slot:action>
<a
class="q-btn"
color="white"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/lnbits/lnbits/releases"
v-text="$t('releases')"
></a>
</template>
</q-banner>
<q-banner v-if="!updateAvailable" class="bg-green text-white">
<q-icon size="28px" name="checkmark"></q-icon>
<span v-text="$t('latest_update', {version: statusData.version})"></span>
</q-banner>
<q-card>
<q-card-section>
<q-table
dense
flat
:data="statusData.notifications"
:columns="statusDataTable.columns"
:no-data-label="$t('no_notifications')"
>
<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"
v-text="col.label"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.type === 'update'"
size="18px"
name="update"
color="green"
></q-icon>
<q-icon
v-if="props.row.type === 'warning'"
size="18px"
name="warning"
color="red"
></q-icon>
</q-td>
<q-td
auto-width
key="date"
:props="props"
v-text="formatDate(props.row.date)"
>
</q-td>
<q-td key="message" :props="props"
><span v-text="props.row.message"></span
><a
v-if="props.row.link"
target="_blank"
rel="noopener noreferrer"
:href="props.row.link"
v-text="$t('more')"
></a
></q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
+87 -115
View File
@@ -1,145 +1,144 @@
<q-tab-panel name="server">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">Server Management</h6>
<br />
<div>
<div class="row">
<div class="col">
<p>Server Info</p>
<ul>
<li
v-if="settings.lnbits_data_folder"
v-text="'SQlite: ' + settings.lnbits_data_folder"
></li>
<li
v-if="settings.lnbits_database_url"
v-text="'Postgres: ' + settings.lnbits_database_url"
></li>
</ul>
<br />
</div>
</div>
<h6 class="q-my-none"><span v-text="$t('currency_settings')"></span></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Allowed currencies</p>
<p><span v-text="$t('allowed_currencies')"></span></p>
<q-select
filled
v-model="formData.lnbits_allowed_currencies"
multiple
hint="Limit the number of available fiat currencies"
label="Allowed currencies"
:hint="$t('allowed_currencies_hint')"
:label="$t('allowed_currencies')"
:options="{{ currencies | safe }}"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p>Default Accounting Currency</p>
<p><span v-text="$t('default_account_currency')"></span></p>
<q-select
filled
v-model="formData.lnbits_default_accounting_currency"
clearable
hint="Default currency for accounting"
label="Currency"
:hint="$t('default_account_currency_hint')"
:label="$t('currency')"
:options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
></q-select>
<br />
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"><span v-text="$t('payments')"></span></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
<div class="col-12 col-md-3">
<p><span v-text="$t('max_outgoing_payment_amount')"></span></p>
<q-input
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
<br />
type="number"
v-model.number="formData.lnbits_max_outgoing_payment_amount_sats"
:label="$t('max_outgoing_payment_amount')"
step="1"
min="0"
:hint="$t('max_outgoing_payment_amount_desc')"
></q-input>
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
<div class="col-12 col-md-3">
<p><span v-text="$t('max_incoming_payment_amount')"></span></p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_max_incoming_payment_amount_sats"
:label="$t('max_incoming_payment_amount')"
step="1"
min="0"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-12 col-md-6"></div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"><span v-text="$t('wallet_limiter')"></span></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-3">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_max_balance"
:label="$t('wallet_max_ballance')"
></q-input>
</div>
<div class="col-12 col-md-3">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
:label="$t('wallet_limit_max_withdraw_per_day')"
></q-input>
</div>
<div class="col-12 col-md-3">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
:label="$t('wallet_limit_secs_between_trans')"
></q-input>
</div>
<div class="col-12 col-md-3">
<q-toggle
v-model="formData.lnbits_only_allow_incoming_payments"
:label="$t('only_incoming_payments_allowed')"
><q-tooltip v-text="$t('disable_outgoing_payments')"></q-tooltip
></q-toggle>
</div>
</div>
<br />
<h6 class="q-my-none">Service Fee</h6>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"><span v-text="$t('service_fee')"></span></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Service Fee</p>
<p><span v-text="$t('service_fee')"></span></p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee"
label="Service fee (%)"
:label="$t('service_fee_label')"
step="0.1"
hint="Fee charged per tx (%)"
:hint="$t('service_fee_hint')"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Service fee max</p>
<p><span v-text="$t('service_fee_max')"></span></p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee_max"
label="Service fee max (sats)"
hint="Max service fee to charge in (sats)"
:label="$t('service_fee_max_label')"
:hint="$t('service_fee_max_hint')"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Fee Wallet</p>
<p><span v-text="$t('fee_wallet')"></span></p>
<q-input
filled
v-model="formData.lnbits_service_fee_wallet"
label="Fee wallet (wallet ID)"
hint="Wallet ID to send funds to"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Disable Service Fee for Internal Payments</p>
<p><span v-text="$t('disable_fee_internal')"></span></p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Fee</q-item-label>
<q-item-label caption
>Disable Service Fee for Internal Lightning
Payments</q-item-label
>
<q-item-label v-text="$t('disable_fee')"></q-item-label>
<q-item-label
caption
v-text="$t('disable_fee_internal_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
@@ -154,33 +153,6 @@
<br />
</div>
</div>
<h6 class="q-my-none">Extensions</h6>
<div>
<p>Extension Sources</p>
<q-input
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
><span v-text="manifestUrl"></span
></q-chip>
</div>
<br />
</div>
</div>
</q-card-section>
</q-tab-panel>
+95 -43
View File
@@ -1,90 +1,80 @@
<q-tab-panel name="theme">
<q-tab-panel name="site_customisation">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">UI Management</h6>
<h6 class="q-my-none"><span v-text="$t('ui_management')"></span></h6>
<br />
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-5">
<p>Site Title</p>
<p><span v-text="$t('ui_site_title')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_site_title"
label="Site title"
:label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
></q-input>
<br />
</div>
<div class="col-12 col-md-5">
<p>Site Tagline</p>
<p><span v-text="$t('ui_site_tagline')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_site_tagline"
label="Site tagline"
:label="$t('ui_site_tagline')"
></q-input>
<br />
</div>
<div class="col-12 col-md-2 q-mt-xl">
<q-toggle
tip="Remove homepage elements like 'runs on' etc"
v-model="formData.LNBITS_SHOW_HOME_PAGE_ELEMENTS"
:label="formData.LNBITS_SHOW_HOME_PAGE_ELEMENTS ? 'Enable elements on homepage' : 'Disable elements on homepage'"
:tip="$t('ui_toggle_elements_tip')"
v-model="formData.lnbits_show_home_page_elements"
:label="formData.lnbits_show_home_page_elements ? $t('ui_elements_enable') : $t('ui_elements_disable')"
></q-toggle>
</div>
</div>
<div>
<p>Site Description</p>
<p><span v-text="$t('ui_site_description')"></span></p>
<q-input
v-model="formData.lnbits_site_description"
filled
type="textarea"
hint="Use plain text, Markdown, or raw HTML"
:hint="$t('ui_site_description_hint')"
/>
</div>
<br />
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<p>Default Wallet Name</p>
<p><span v-text="$t('ui_default_wallet_name')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_default_wallet_name"
label="LNbits wallet"
:label="$t('lnbits_wallet')"
></q-input>
</div>
<div class="col-12 col-md-4">
<p>Denomination</p>
<q-input
filled
type="text"
v-model="formData.lnbits_denomination"
label="sats"
hint="The name for the FakeWallet token"
></q-input>
</div>
<div class="col-12 col-md-4">
<p>QR code logo</p>
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_qr_logo"
label="https://example.com/image.svg"
hint="URL to logo image in QR code"
:hint="$t('ui_qr_code_logo_hint')"
></q-input>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<p>Custom Badge</p>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('ui_custom_badge')"></span></p>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8">
<q-input
filled
type="text"
tip="Custom Badge"
v-model="formData.lnbits_custom_badge"
label="Custom Badge 'USE WITH CAUTION - LNbits wallet is still in BETA'"
v-model.trim="formData.lnbits_custom_badge"
:label="$t('ui_custom_badge_label')"
></q-input>
</div>
<div class="col-12 col-md-4">
@@ -92,67 +82,129 @@
filled
v-model="formData.lnbits_custom_badge_color"
:options="colors"
label="Custom badge color"
:label="$t('ui_custom_badge_color_label')"
></q-select>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('ui_custom_image')"></span></p>
<q-input
filled
type="text"
tip="Custom Image"
v-model.trim="formData.lnbits_custom_image"
:label="$t('ui_custom_image_label')"
:hint="$t('ui_custom_image_hint')"
></q-input>
</div>
</div>
<br />
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Themes</p>
<p><span v-text="$t('themes')"></span></p>
<q-select
filled
v-model="formData.lnbits_theme_options"
multiple
hint="Choose themes available for users"
:hint="$t('themes_hint')"
:options="lnbits_theme_options"
label="Themes"
:label="$t('themes')"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p>Custom Logo</p>
<p><span v-text="$t('custom_logo')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_custom_logo"
label="https://example.com/image.png"
hint="URL to logo image"
:hint="$t('custom_logo_hint')"
></q-input>
<br />
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Ad Space Title</p>
<p><span v-text="$t('ad_space_title')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_ad_space_title"
label="Supported by"
:label="$t('ad_space_title_label')"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p>Advertisement Slots</p>
<p><span v-text="$t('ad_slots')"></span></p>
<q-input
class="q-mb-md"
filled
v-model="formData.lnbits_ad_space"
type="text"
label="url;img_light_url;img_dark_url, url..."
hint="Ad url and image filepaths in CSV format, extensions can choose to honor"
>
:label="$t('ad_slots_label')"
:hint="$t('ad_slots_hint')"
><q-tooltip>
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
</q-tooltip>
</q-input>
<q-toggle
v-model="formData.lnbits_ad_space_enabled"
:label="formData.lnbits_ad_space_enabled ? 'Ads enabled' : 'Ads disabled'"
:label="formData.lnbits_ad_space_enabled ? $t('ads_enabled') : $t('ads_disabled')"
/>
<br />
</div>
</div>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12" v-text="$t('ui_default_theme')"></div>
<div class="col-12 col-sm-6 col-lg-3">
<q-select
v-model="formData.lnbits_default_border"
:options="globalBorderOptions"
label="Border"
@update:model-value="applyGlobalBorder"
>
</q-select>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<q-select
v-model="formData.lnbits_default_theme"
:options="lnbits_theme_options"
label="Theme"
@update:model-value="applyGlobalTheme"
>
</q-select>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<q-select
v-model="formData.lnbits_default_reaction"
:options="reactionOptions"
label="Payment reaction"
@update:model-value="applyGlobalReaction"
>
</q-select>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<q-input
type="text"
v-model="formData.lnbits_default_bgimage"
label="Background Image"
@update:model-value="applyGlobalBgimage"
hint="This must be a trusted source. It can change the content and it can log your IP address."
>
</q-input>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<q-toggle
type="bool"
v-model="formData.lnbits_default_gradient"
color="primary"
label="Gradient Background"
>
</q-toggle>
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
+20 -15
View File
@@ -1,17 +1,19 @@
<q-tab-panel name="users">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">User Management</h6>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('user_management')"></span>
</h6>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Admin Users</p>
<div class="col-12 col-md-6 q-pr-sm">
<p><span v-text="$t('admin_users')"></span></p>
<q-input
filled
v-model="formAddAdmin"
@keydown.enter="addAdminUser"
type="text"
label="User ID"
hint="Users with admin privileges"
:label="$t('admin_users_label')"
:hint="$t('admin_users_hint')"
>
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
</q-input>
@@ -23,21 +25,22 @@
@remove="removeAdminUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
<span v-text="user"></span>
</q-chip>
</div>
<br />
</div>
<div class="col-md-6 col-sm-12">
<p>Allowed Users</p>
<div class="col-12 col-md-6">
<p><span v-text="$t('allowed_users')"></span></p>
<q-input
filled
v-model="formAddUser"
@keydown.enter="addAllowedUser"
type="text"
label="User ID"
hint="Only these users can use LNbits"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint')"
>
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
</q-input>
@@ -49,17 +52,19 @@
@remove="removeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
<span v-text="user" />
</q-chip>
</div>
<br />
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Allow creation of new users</q-item-label>
<q-item-label caption
>Allow creation of new users on the index page</q-item-label
>
<q-item-label v-text="$t('allow_creation_user')"></q-item-label>
<q-item-label
caption
v-text="$t('allow_creation_user_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
+221 -83
View File
@@ -1,113 +1,251 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col q-my-md">
<q-btn
:label="$t('save')"
color="primary"
@click="updateSettings"
:disabled="!checkChanges"
>
<q-tooltip v-if="checkChanges">
<span v-text="$t('save_tooltip')"></span>
</q-tooltip>
{% if not ajax %} {% extends "base.html" %} {% endif %} {% from "macros.jinja"
import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
{% endblock %} {% block page %}
<q-badge
v-if="checkChanges"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
:label="$t('save')"
color="primary"
@click="updateSettings"
:disabled="!checkChanges"
>
<q-tooltip v-if="checkChanges">
<span v-text="$t('save_tooltip')"></span>
</q-tooltip>
<q-btn
v-if="isSuperUser"
:label="$t('restart')"
color="primary"
@click="restartServer"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="checkChanges"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-badge
v-if="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
v-if="isSuperUser"
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
</q-tooltip>
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
<q-badge
v-if="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
flat
v-if="isSuperUser"
:label="$t('reset_defaults')"
color="primary"
@click="deleteSettings"
class="float-right"
>
<q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn>
<q-btn
:label="$t('download_backup')"
flat
@click="downloadBackup"
></q-btn>
<q-btn
flat
v-if="isSuperUser"
:label="$t('reset_defaults')"
color="primary"
@click="deleteSettings"
class="float-right"
>
<q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn>
</div>
<div></div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<div class="q-pa-md">
<div class="q-gutter-y-md">
<q-tabs v-model="tab" active-color="primary" align="justify">
<q-splitter>
<template v-slot:before>
<q-tabs
@update:model-value="showExchangeProvidersTab"
v-model="tab"
vertical
active-color="primary"
>
<q-tab
name="funding"
:label="$t('funding')"
icon="account_balance_wallet"
:label="$q.screen.gt.sm ? $t('funding') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('funding')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
:label="$t('users')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="server"
:label="$t('server')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="security"
:label="$t('security')"
icon="security"
:label="$q.screen.gt.sm ? $t('security') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('security')"></span></q-tooltip
></q-tab>
<q-tab
name="server"
icon="price_change"
:label="$q.screen.gt.sm ? $t('payments') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('payments')"></span></q-tooltip
></q-tab>
<q-tab
name="exchange_providers"
icon="show_chart"
:label="$q.screen.gt.sm ? $t('exchanges') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab>
<q-tab
name="fiat_providers"
icon="credit_score"
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('fiat_providers')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
icon="group"
:label="$q.screen.gt.sm ? $t('users') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('users')"></span></q-tooltip
></q-tab>
<q-tab
name="theme"
:label="$t('theme')"
name="extensions"
icon="extension"
:label="$q.screen.gt.sm ? $t('extensions') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('extensions')"></span></q-tooltip
></q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span></q-tooltip
></q-tab>
<q-tab
name="audit"
icon="playlist_add_check_circle"
:label="$q.screen.gt.sm ? $t('audit') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
name="library"
icon="image"
:label="$q.screen.gt.sm ? $t('library') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('library')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
icon="language"
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
</q-tabs>
</div>
</div>
</template>
<q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
include "admin/_tab_security.html" %} {% include
"admin/_tab_theme.html" %}
</q-tab-panels>
</q-form>
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-scroll-area style="height: 100vh">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
scroll
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
%} {% include "admin/_tab_exchange_providers.html" %}{% include
"admin/_tab_fiat_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-scroll-area>
</q-form>
</template>
</q-splitter>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="q-mb-md">
<strong v-text="$t('create_ticker_converter')"></strong>
</div>
<div class="row">
<div class="col-12 q-mb-md">
<q-select
filled
dense
v-model="exchangeData.convertFromTicker"
label="From Currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
<div class="col-12">
<q-input
v-model="exchangeData.convertToTicker"
dense
filled
label="New Ticker"
hint="This ticker will be used for the exchange API calls."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="addExchangeTickerConversion()"
label="Add Ticker Conversion"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
+201
View File
@@ -0,0 +1,201 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<!-- Optional: Add content here if needed -->
</div>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#audit"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center q-mb-lg">
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('components')"></strong>
<div style="width: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="componentUseChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('long_running_endpoints')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="longDurationChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_request_methods')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="requestMethodChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_response_codes')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="auditEntries"
:columns="auditTable.columns"
v-model:pagination="auditTable.pagination"
:filter="auditTable.search"
:loading="auditTable.loading"
@request="fetchAudit"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchAuditBy()"
@update:model-value="searchAuditBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchAuditBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-select
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchAuditBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'created_at'">
<q-btn
icon="description"
:disable="!props.row.request_details"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="showDetailsDialog(props.row)"
>
<q-tooltip
><span v-text="$t('request_details')"></span
></q-tooltip>
</q-btn>
<span v-text="formatDate(props.row.created_at)"></span>
<q-tooltip v-if="props.row.delete_at">
<span
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
></span>
</q-tooltip>
</div>
<div
v-else-if="['user_id', 'request_details'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name])"> </span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
@click="searchAuditBy(col.name, props.row[col.name])"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
<q-dialog v-model="auditDetailsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong v-text="$t('http_request_details')"></strong>
<q-input
filled
dense
v-model.trim="auditDetailsDialog.data"
type="textarea"
rows="25"
></q-input>
<div class="row q-mt-lg">
<q-btn
@click="copyText(auditDetailsDialog.data)"
icon="copy_content"
color="grey"
flat
v-text="$t('copy')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
+178 -27
View File
@@ -5,10 +5,97 @@
:content-inset-level="0.5"
>
<q-card-section>
<strong>Node URL: </strong><em v-text="origin"></em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText(wallet.id)"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.adminkey"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="inkeyHidden = !inkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.inkey)"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.inkey"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-expansion-item
group="api"
@@ -20,7 +107,12 @@
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -30,12 +122,14 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl {{ request.base_url }}api/v1/wallet -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>"</code
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
@@ -46,7 +140,12 @@
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
@@ -62,9 +161,11 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
@@ -77,9 +178,23 @@
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<code
><span class="text-light-green">POST</span> /api/v1/payments (reveal
admin keys
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon
>)</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.adminkey }}"}</code>
<code
>{"X-Api-Key": "<i
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
@@ -88,10 +203,10 @@
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": true,
"bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
application/json"</code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": true, "bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
@@ -109,18 +224,16 @@
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"invoice": &lt;string&gt;}</code>
<code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
@@ -139,18 +252,56 @@
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}api/v1/payments/&lt;payment_hash&gt; -H "X-Api-Key:
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<code
><span class="text-pink">WS</span>
/api/v1/ws/&lt;invoice_key&gt;</code
>
<h5
class="text-caption q-mt-sm q-mb-none"
v-text="$t('websocket_example')"
></h5>
<code
>wscat -c <span v-text="websocketUrl"></span>/<span
v-text="inkeyHidden ? '****************' : wallet.inkey"
></span
></code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)/payments
</h5>
<code>{"balance": &lt;int&gt;, "payment": &lt;object&gt;}</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-card-section>
<p v-text="$t('reset_wallet_keys_desc')"></p>
<q-btn
unelevated
color="red-10"
@click="resetKeys()"
:label="$t('reset_wallet_keys')"
></q-btn>
</q-card-section>
</q-expansion-item>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+144 -133
View File
@@ -1,139 +1,150 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center main">
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section class="grid">
<div>
<h6 class="q-my-none text-center">
<strong>Welcome to LNbits</strong>
<p>Set up the Superuser account below.</p>
</h6>
<br />
<q-form class="q-gutter-md">
<q-input
filled
v-model="loginData.username"
:label="$t('username')"
></q-input>
<q-input
filled
v-model.trim="loginData.password"
:type="loginData.isPwd ? 'password' : 'text'"
autocomplete="off"
:label="$t('password')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.passwordRepeat"
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
color="primary"
:label="$t('login')"
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
></q-btn>
</q-form>
</div>
<div class="hero-wrapper">
<div class="hero q-mx-auto"></div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
}
.grid {
display: block;
}
.hero-wrapper {
display: none;
}
.hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
{% extends "public.html" %} {% block page_container %}
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}
<div class="row q-col-gutter-md justify-center main">
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section class="grid">
<div>
<h6 class="q-my-none text-center">
<strong v-text="$t('welcome_lnbits')"></strong>
<p><span v-text="$t('setup_su_account')"></span></p>
</h6>
<br />
<q-form class="q-gutter-md">
<q-input
filled
v-model="loginData.username"
:label="$t('username')"
></q-input>
<q-input
filled
v-model.trim="loginData.password"
:type="loginData.isPwd ? 'password' : 'text'"
autocomplete="off"
:label="$t('password')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.passwordRepeat"
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
color="primary"
:label="$t('login')"
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
></q-btn>
</q-form>
</div>
<div class="hero-wrapper">
<div class="hero q-mx-auto"></div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
}
.grid {
display: block;
}
.hero-wrapper {
display: none;
}
.hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
@media (min-width: 992px) {
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.hero-wrapper {
display: block;
position: relative;
height: 100%;
padding: 1rem;
}
}
</style>
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
loginData: {
isPwd: true,
isPwdRepeat: true,
username: '',
password: '',
passwordRepeat: ''
@media (min-width: 992px) {
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.hero-wrapper {
display: block;
position: relative;
height: 100%;
padding: 1rem;
}
}
},
created() {
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
},
computed: {
checkPasswordsMatch() {
return this.loginData.password !== this.loginData.passwordRepeat
}
},
methods: {
async setPassword() {
try {
await LNbits.api.request('PUT', '/api/v1/auth/first_install', null, {
username: this.loginData.username,
password: this.loginData.password,
password_repeat: this.loginData.passwordRepeat
})
</style>
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data: function () {
return {
loginData: {
isPwd: true,
isPwdRepeat: true,
username: '',
password: '',
passwordRepeat: ''
}
}
},
created() {
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
},
computed: {
checkPasswordsMatch() {
return this.loginData.password !== this.loginData.passwordRepeat
}
},
methods: {
async setPassword() {
try {
await LNbits.api.request(
'PUT',
'/api/v1/auth/first_install',
null,
{
username: this.loginData.username,
password: this.loginData.password,
password_repeat: this.loginData.passwordRepeat
}
)
window.location.href = '/admin'
} catch (e) {
LNbits.utils.notifyApiError(e)
window.location.href = '/admin'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
}
}
})
</script>
})
</script>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
@click="showAddWalletDialog.show = true"
:label="$t('add_wallet')"
color="primary"
>
</q-btn>
</div>
<div class="float-left">
<q-input
:label="$t('search_wallets')"
dense
class="float-right q-pr-xl"
v-model="walletsTable.search"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="walletsTable.search !== ''"
name="close"
@click="walletsTable.search = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div>
<div>
<div>
<q-table
grid
grid-header
flat
bordered
:rows="wallets"
:columns="walletsTable.columns"
v-model:pagination="walletsTable.pagination"
:loading="walletsTable.loading"
@request="getUserWallets"
row-key="id"
:filter="filter"
hide-header
>
<template v-slot:item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
<q-card
class="q-ma-sm cursor-pointer wallet-list-card"
style="text-decoration: none"
@click="goToWallet(props.row.id)"
>
<q-card-section>
<div class="row items-center">
<q-avatar
size="lg"
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
:color="props.row.extra.color"
:icon="props.row.extra.icon"
>
</q-avatar>
<div
class="text-h6 q-pl-md ellipsis"
class="text-bold"
v-text="props.row.name"
></div>
<q-space> </q-space>
<q-btn
v-if="props.row.extra.pinned"
round
color="primary"
text-color="black"
size="xs"
icon="push_pin"
class="float-right"
style="transform: rotate(30deg)"
></q-btn>
</div>
<div class="row items-center q-pt-sm">
<h6 class="q-my-none ellipsis full-width">
<strong
v-text="formatBalance(props.row.balance_msat / 1000)"
></strong>
</h6>
</div>
</q-card-section>
<q-separator />
<q-card-section class="text-left">
<small>
<strong>
<span v-text="$t('currency')"></span>
</strong>
<span v-text="props.row.currency || 'sat'"></span>
</small>
<br />
<small>
<strong>
<span v-text="$t('id')"></span>
:
</strong>
<span v-text="props.row.id"></span>
</small>
</q-card-section>
</q-card>
</div>
</template>
</q-table>
</div>
</div>
</div>
<q-dialog
v-model="showAddWalletDialog.show"
persistent
@hide="showAddWalletDialog = {show: false}"
>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">
<span v-text="$t('wallet_name')"></span>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
dense
v-model="showAddWalletDialog.name"
autofocus
@keyup.enter="submitAddWallet()"
></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
<q-btn
flat
:label="$t('add_wallet')"
v-close-popup
@click="submitAddWallet()"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More