Compare commits

..
192 Commits
Author SHA1 Message Date
dni ⚡ b0ff997ec1 make fe tests run 2025-12-23 11:14:47 +01:00
dni ⚡ eb55170905 prettier 2025-12-23 11:01:41 +01:00
dni ⚡ dc466ae738 bundle 2025-12-23 10:59:30 +01:00
dni ⚡ dd5fe8b7a2 bundle 2025-12-23 10:59:04 +01:00
dni ⚡ 5196ee6a15 reverting 2025-12-23 10:59:00 +01:00
dni ⚡ 3498cfb5b8 build 2025-12-23 10:58:58 +01:00
dni ⚡ 30ffe437a0 add watch 2025-12-23 10:58:57 +01:00
dni ⚡ 574641d432 fixup! 2025-12-23 10:58:56 +01:00
dni ⚡ 90d14629d9 build umd module 2025-12-23 10:58:54 +01:00
dni ⚡ 3be06269c2 build umd components 2025-12-23 10:58:53 +01:00
dni ⚡ d7723696d8 fixi 2025-12-23 10:58:52 +01:00
dni ⚡ 91f7b7730d betweeni 2025-12-23 10:58:51 +01:00
dni ⚡ b2aaf97cf3 test: add frontend tests for vue components 2025-12-23 10:58:46 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
8e0d1cc100 chore(deps): bump fastapi-sso from 0.18.0 to 0.19.0 (#3681)
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-12-23 09:18:33 +01:00
dni ⚡andGitHub cb3fd56647 chore: update to version v1.4.0 (#3684) 2025-12-22 14:34:03 +01:00
Vlad StanandGitHub 9f383bfee6 fix: cached user check (#3682) 2025-12-22 10:24:07 +01:00
dni ⚡andGitHub 3b2e28dd60 chore: update to v1.4.0-rc4 (#3675) 2025-12-22 09:27:16 +01:00
132192bc94 test: add boltz fundingsource to regtest (#3677)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-22 09:23:46 +01:00
281c3df826 fix: unlimited admin upload (#3679)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2025-12-19 20:11:34 +00:00
Vlad StanandGitHub 08591f34c2 fix: extension spinner not stopping after install (#3680) 2025-12-19 20:01:04 +00:00
ArcandGitHub f0e8ae0f5c Adds support for stripe readers (#3678) 2025-12-19 08:23:56 +01:00
168cb726b1 Make funding source fields more explicit (#3676)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-18 13:44:22 +00:00
dni ⚡andGitHub 91ac245307 fix: release lnbits-boltz with proper tag (#3674) 2025-12-17 14:36:29 +01:00
dni ⚡andGitHub d098e2f710 chore: update to v1.4.0-rc3 (#3672) 2025-12-17 13:57:22 +01:00
dni ⚡andGitHub a0f65f4cda fix: Boltzclient fundingsource. use a hashed wallet_name (#3673) 2025-12-17 13:57:05 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
606ee215b4 chore(deps-dev): bump filelock from 3.18.0 to 3.20.1 (#3669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-17 09:13:18 +01:00
dni ⚡andGitHub 7716f819f6 CI: create github prerelease and appimage (#3668) 2025-12-17 07:21:47 +01:00
arcbtc 8325d880cb Extra precaution
Just in case someone in lnbits org creates a test release on a fork
2025-12-16 13:45:52 +00:00
dni ⚡andGitHub b090470fc1 fix: CI getting release upload url (#3667) 2025-12-16 14:12:00 +01:00
dni ⚡andGitHub c7297d2e77 feat: add the ability to print qrcodes to <lnbits-qrcode> component (#3664) 2025-12-16 12:12:11 +00:00
dni ⚡andGitHub b6e111b21c fix: dont log routes.json 404 (#3665) 2025-12-16 13:10:02 +01:00
dni ⚡andGitHub 7747d7b741 fix: path check for ext pages with arguments (#3660) 2025-12-11 13:56:49 +01:00
Tiago VasconcelosandGitHub f3a5a8e002 fix: account dropdown (#3658) 2025-12-11 13:17:17 +01:00
Tiago VasconcelosandGitHub 157a6485b4 fix: restore the blur on drawer (#3654) 2025-12-11 13:16:32 +01:00
dni ⚡andGitHub 8867b27b09 fix: LOCALE global variable still used in extension (#3659) 2025-12-11 13:03:38 +01:00
dni ⚡andGitHub 68b607ecbc fix: add spacing to the logo (#3653) 2025-12-10 08:08:00 +01:00
dni ⚡andGitHub f411fd13dc chore: update to v1.4.0-rc2 (#3650) 2025-12-09 14:50:36 +02:00
baee90da67 feat: Adds paypal as a fiat choice (#3637)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-09 14:35:39 +02:00
dni ⚡andGitHub 661b713993 feat: extensions, installed tab as default (#3649) 2025-12-09 12:05:09 +01:00
dni ⚡andGitHub 5a5f253fa5 fix: extension builder did not show the content (#3648) 2025-12-09 11:02:37 +01:00
dni ⚡andGitHub 77a5d7fe50 fix: currencies in receive dialog (#3646) 2025-12-09 10:05:32 +01:00
Vlad StanandGitHub 07dd4fc685 fix: run_interval call sleep even if it fails (#3647) 2025-12-09 10:59:43 +02:00
dni ⚡andGitHub 3761f7922c fix: reload loop in /upgrades/ routes + error status_code (#3645) 2025-12-09 09:24:51 +01:00
dni ⚡andGitHub 327b9d7f63 fix: extension builder preview was in reload loop (#3643) 2025-12-09 09:21:38 +01:00
dni ⚡andGitHub cacffc67ee fix: dynamic extension loading did not use cache key (#3641) 2025-12-08 15:44:18 +01:00
b7fdf99a8d Fix: Add a QR and Copy button (#3640)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-08 15:28:18 +01:00
cd6cfff9cf feat: sort payments in wallet (#3642)
Co-authored-by: dni  <office@dnilabs.com>
2025-12-08 16:24:05 +02:00
7b635a31e5 fix: the username and picture (#3638)
Co-authored-by: dni  <office@dnilabs.com>
2025-12-08 15:03:29 +01:00
dni ⚡andGitHub b4c0cdbc7c fix: static extension public page wrong redirect (#3639) 2025-12-08 13:38:57 +01:00
dni ⚡andGitHub fd765e2060 feat: dynamic extension loading via routes.json (#3605) 2025-12-08 11:28:09 +01:00
dni ⚡andGitHub 5f86627eae feat: improve on create wallet frontend and api. (BREAKING CHANGE) (#3635) 2025-12-08 11:09:43 +01:00
3af3838995 feat: customizable Apple touch icon (#3606)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-12-08 10:46:02 +01:00
Tiago VasconcelosandGitHub a762529fef Fix: account dropdown improvements (#3636) 2025-12-07 13:35:04 +01:00
245569d0b9 feat: stripe api Intents needed for tap to pay (#3598)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-12-06 17:50:53 +01:00
dni ⚡andGitHub fcaeb0ac7a fix: typo in walletShareInvoice (#3597) 2025-12-06 16:33:34 +01:00
dni ⚡andGitHub d2aedde21b fix: websocket only listen to filtered wallets (#3627) 2025-12-06 16:31:12 +01:00
5adc419c74 fix: restore default exchanges (#3613)
Co-authored-by: dni  <office@dnilabs.com>
2025-12-06 16:53:53 +02:00
Vlad StanandGitHub 5d79327906 [perf] reuse connection (#3624) 2025-12-06 14:52:06 +01:00
dni ⚡andGitHub 71e0b396d2 fix: deleting the wallet did not update lastActiveWallet (#3628) 2025-12-06 14:46:17 +01:00
dni ⚡andGitHub 89d673448a fix: /error should not be a generic route (#3629) 2025-12-06 14:38:52 +01:00
dni ⚡andGitHub aed3f3b569 feat: improve on lnbits-qrcode-scanner design (#3633) 2025-12-06 14:38:12 +01:00
dni ⚡andGitHub d9a2a7bb95 feat: add right arrow for drawer active menu items (#3631) 2025-12-06 14:18:11 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
15ede13104 chore(deps): bump urllib3 from 2.5.0 to 2.6.0 (#3630)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-06 14:16:10 +01:00
dni ⚡andGitHub 62047ea758 fix: exchange rate was not shown on wallet page when switching wallets (#3634) 2025-12-06 14:12:09 +01:00
dni ⚡andGitHub e5e589fc40 fix: linebreak and cleanup account menu (#3625) 2025-12-05 22:24:45 +01:00
dni ⚡andGitHub 46406792fe feat: improve account navigation (#3623) 2025-12-05 09:45:02 +01:00
dni ⚡andGitHub dbf71fed53 feat: add vue router navigation to /admin (#3622) 2025-12-05 09:25:47 +01:00
Vlad StanandGitHub 850087a8ec [perf] Faster require invoice key (#3603) 2025-12-05 10:03:51 +02:00
dni ⚡andGitHub d9b045c526 chore: clean components.vue from jinja rendering (#3621) 2025-12-05 08:35:00 +01:00
dni ⚡andGitHub e1ca6ef82a fix: href on ads got removed (#3620) 2025-12-05 08:33:18 +01:00
17c40d539e fix: add spacing login page and wrong homepagebutton enabled settings (#3619)
Co-authored-by: dni  <office@dnilabs.com>
2025-12-04 13:34:11 +01:00
dni ⚡andGitHub 625fa6503c feat: dynamic login and registering (#3604) 2025-12-04 11:50:45 +01:00
73634e5161 chore: cleanup base.html, minor issue and g.user initialisation (#3615)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-04 10:21:40 +01:00
dni ⚡andGitHub 1fe35070f4 fix: extension stayed active in frontend extension list (#3617) 2025-12-04 10:05:40 +01:00
Vlad StanandGitHub ad268516a9 [perf] Extension list cache (#3616) 2025-12-04 10:57:17 +02:00
dni ⚡andGitHub ca94909aab fix: qrcode import changed in qrcode.vue package (#3618) 2025-12-04 09:55:25 +01:00
Vlad StanandGitHub b3efb4d378 perf: use check_account_exists decorator (#3600) 2025-12-04 10:17:47 +02:00
dni ⚡andGitHub 5213508dc1 feat: update npm packages use terser to minify (#3614) 2025-12-04 08:20:01 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
af9331eede chore(deps-dev): bump werkzeug from 3.1.3 to 3.1.4 (#3608)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 08:19:02 +01:00
9b8fe42102 feat: change default table pagination (#3607)
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-02 21:59:13 +01:00
dni ⚡andGitHub a0e4a59454 fix: wallet page g.user (#3611) 2025-12-02 13:45:54 +01:00
dni ⚡andGitHub eefaf3c50c feat: add dynamic errorpage (#3602) 2025-12-02 13:33:25 +01:00
dni ⚡andGitHub 89cabda123 feat: all <img> beeing lazyloaded (#3610) 2025-12-02 12:23:00 +01:00
dni ⚡andGitHub 6122a03f32 chore: replace secp256k1 with coincurve (#3609) 2025-12-02 09:41:49 +01:00
dni ⚡andGitHub ad8b70a098 fix: g.wallet artifacts in lnbits-payment-list and lnbits-manage-wall... (#3601) 2025-12-01 09:10:40 +01:00
6449276003 feat: move qrcode scanner into reuseable component (#3567)
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2025-11-28 17:58:50 +01:00
dni ⚡andGitHub 4da651b74a fix: backwards compatibility extension formatDateString (#3594) 2025-11-27 14:16:46 +01:00
dni ⚡andGitHub b5f3a46feb fix: wallet chart render racecondition (#3595) 2025-11-27 13:31:17 +01:00
dni ⚡andGitHub 1057b4693f fix: typo in /wallet redirect (#3593) 2025-11-27 13:08:02 +01:00
dni ⚡andGitHub 5711b4b804 feat: simplify extension page component and make filtering reactive (#3589) 2025-11-27 12:58:08 +01:00
dni ⚡andGitHub 704e5a1b73 refactor: use utils.copytext (#3590) 2025-11-27 12:31:14 +01:00
dni ⚡andGitHub e6ca8a33c6 refactor: move copyText and formatBalance into utils (#3584) 2025-11-27 12:23:42 +01:00
dni ⚡andGitHub 9c257aa23d feat: remove selectWallet and use path params instead of url params (#3591) 2025-11-27 12:17:01 +01:00
dni ⚡andGitHub ef752cbeca fix: annoying css glitch (#3592) 2025-11-27 11:42:44 +02:00
dni ⚡andGitHub a509eb8fdb fix: regression receive dialog was not closing (#3587) 2025-11-26 20:48:23 +01:00
dni ⚡andGitHub 49cc8104fc chore: move decryptLnurlPayAES to utils (#3576) 2025-11-26 19:40:54 +01:00
dni ⚡andGitHub f1f6af0e35 feat: change utils.formatDate and utils.formatTimestamp (#3583) 2025-11-26 18:54:09 +01:00
Vlad StanandGitHub 5f1cfc0e37 [perf] send payment notifications in background (#3588) 2025-11-26 18:53:10 +01:00
dni ⚡andGitHub 730ab59578 fix: show all currency if not specified (#3586) 2025-11-26 17:13:47 +01:00
dni ⚡andGitHub c8bdba8e53 fix: regression #3580 payment list amount wrong if (#3585) 2025-11-26 17:07:09 +01:00
dni ⚡andGitHub c6c67c52db chore: cleanup LNBITS_DENOMINATION global var (#3580) 2025-11-26 15:15:20 +01:00
da31e3caaa fix: add status to csv export (#3578)
Co-authored-by: dni  <office@dnilabs.com>
2025-11-26 14:35:47 +01:00
ArcandGitHub d0bf47163b fix: button hide fix (#3579) 2025-11-26 14:32:11 +01:00
dni ⚡andGitHub 37ad515427 fix: change button label on wallet (#3581) 2025-11-26 13:26:22 +00:00
dni ⚡andGitHub 8c77f75cf1 chore: move map payment into lnbits-payment-list (#3572) 2025-11-26 13:27:54 +01:00
dni ⚡andGitHub 21505471d5 fix: mapping of user was done each time component was initialised (#3573) 2025-11-26 13:21:36 +01:00
dni ⚡andGitHub baa9a35773 fix: cleanup paymentEvents, simplify websockets (#3570) 2025-11-26 13:11:27 +01:00
ArcandGitHub 7f114ddcc0 fix: better naming (#3577) 2025-11-26 11:54:02 +00:00
dni ⚡andGitHub 92aad20dd7 fix: temporarly removed utils but used it for logout (#3574) 2025-11-26 11:20:01 +00:00
dni ⚡andGitHub 0f4ae5da86 feat: create wallet if user does not have one (#3566) 2025-11-25 16:18:45 +01:00
dni ⚡andGitHub 7a796c6510 chore: refactor windowMixin, init-app.js, lnbits-theme (#3569) 2025-11-25 14:31:17 +01:00
0910687328 [perf] pending payments check (#3565)
Co-authored-by: dni  <office@dnilabs.com>
2025-11-25 14:09:57 +02:00
dni ⚡andGitHub 33e2fc2ea8 feat: move wallet.html to vue component. FINAL DynamicComponent pr (#3559) 2025-11-25 11:45:53 +01:00
DoktorShiftandGitHub 0c6e8394c8 Update README.md with new TipJar badges and links (#3563) 2025-11-25 09:40:50 +02:00
Vlad StanandGitHub d55e2a0e1f [fix] user sorting performance (#3561) 2025-11-25 09:16:35 +02:00
dni ⚡andGitHub 148ba9d275 fix: robots.txt containing newline and whitespaces (#3560) 2025-11-24 16:26:21 +01:00
Tiago VasconcelosandGitHub d2ca774f6f fix: hide splitter on mobile (#3558) 2025-11-24 13:23:19 +02:00
dni ⚡andGitHub 233398b512 refactor: lnbits-wallet-extra the expandables in the sidebar (#3550) 2025-11-24 10:44:57 +01:00
dni ⚡andGitHub 152c1dbb74 feat: footer remove site_title condition (#3557) 2025-11-24 10:38:17 +01:00
Vlad StanandGitHub 44985cb0d1 [perf] Performance bust paginated search (#3543) 2025-11-21 18:29:23 +02:00
dni ⚡andGitHub b1a7692ce4 refactor: move to lnbits-wallet-icon vue component (#3535) 2025-11-21 11:53:20 +01:00
dni ⚡andGitHub 31bff37b1e fix: pagination vanished on chrome (#3549) 2025-11-21 11:36:18 +01:00
dni ⚡andGitHub 3f7a1798d5 refactor: create lnbits-wallet-paylinks component (#3523) 2025-11-21 11:05:38 +01:00
dni ⚡andGitHub 98d4dbab8b fix: payment labels route dynamically to account#labels (#3548) 2025-11-21 10:58:44 +02:00
Vlad StanandGitHub 7c7a04da9d [feat] Payment labels (#3537) 2025-11-21 10:33:53 +02:00
dni ⚡andGitHub 3ccefb70fa chore: getting rid of jinja variable in wallet.html (#3547) 2025-11-21 09:01:53 +01:00
dni ⚡andGitHub 5346ac47b1 feat: move disclaimer from wallet.html into vue component (#3536) 2025-11-21 08:39:17 +01:00
dni ⚡andGitHub 9f4ed53888 chore: remove unused full withdraw (#3544) 2025-11-21 08:38:09 +01:00
dni ⚡andGitHub faac56c14c refactor: lnbits-wallet-charts and clean up paymentFiltering (#3526) 2025-11-20 15:01:37 +01:00
dni ⚡andGitHub 3e0d0d9896 fix: theme global initialisation when not defined was wrong (#3542) 2025-11-20 11:59:00 +01:00
b9a004a5e4 fix: login screen layout (#3538)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-11-20 10:44:56 +02:00
Tiago VasconcelosandGitHub da3cb548bd fix: clarify payment wait time (#3540) 2025-11-19 15:50:51 +02:00
dni ⚡andGitHub 4144359617 refactor: lnbits-wallet-ads, ads on wallet page (#3534) 2025-11-17 09:14:34 +01:00
Tiago VasconcelosandGitHub 07268b87d2 fix: allow str instead of number (#3531) 2025-11-17 09:29:16 +02:00
dni ⚡andGitHub c356874bcb fix: check for null string on localstorage (#3529) 2025-11-14 14:15:04 +01:00
dni ⚡andGitHub f9b5c7db7a refactor: rename and fix lnbits-wallet-new (#3528) 2025-11-14 14:08:40 +01:00
dni ⚡andGitHub 20f9ddb57c fix: chart color for darkmode (#3525) 2025-11-13 17:12:35 +01:00
dni ⚡andGitHub ee57ba4c1a fix: regression in node.js (#3521) 2025-11-13 16:07:09 +01:00
dni ⚡andGitHub ff7f5c1ca5 refactor: move _wallet-share.html to vue component (#3522) 2025-11-13 15:48:46 +01:00
dni ⚡andGitHub 1463d75ee2 refactor: move _api_docs.html into vue component (#3520) 2025-11-13 15:37:59 +01:00
dni ⚡andGitHub f1fc4710ee refactor: move walletTypes from mixin into lnbits-new-user-wallet (#3519) 2025-11-13 13:29:43 +01:00
dni ⚡andGitHub 00eaec8290 refactor: use mobileSimple reactive value (#3518) 2025-11-13 13:17:27 +01:00
dni ⚡andGitHub 28c4235c94 refactor: rename payment-list to lnbits-payment-list (#3517) 2025-11-13 12:20:51 +01:00
dni ⚡andGitHub b7d178c08e feat: refactor create lnbits-theme vue component (#3515) 2025-11-13 11:50:33 +01:00
dni ⚡andGitHub 9d0ec97d39 refactor: create last base.html vue component (#3514) 2025-11-12 15:16:13 +01:00
dni ⚡andGitHub b6f533be24 feat: add language to topmenu + globals cleanup + i18n refactor (#3510) 2025-11-12 14:07:53 +00:00
dni ⚡andGitHub 5e36bb4fcd fix: bug in renaming lnbits-extension-list (#3513) 2025-11-12 14:13:04 +01:00
dni ⚡andGitHub f9b0604711 fix: extension enable reactivity (#3512) 2025-11-12 13:41:01 +01:00
Vlad StanandGitHub 39c33699af [feat] user assets (#3504) 2025-11-12 14:30:27 +02:00
c89721223f feat: add configurable threshold for balance delta notification (#3433)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-11-12 11:36:19 +02:00
dni ⚡andGitHub 1e6e97c12d refactor: move logout to utils (#3509) 2025-11-12 10:14:27 +01:00
dni ⚡andGitHub 783efb19db fix: wallet was not flipping (#3508) 2025-11-12 09:54:54 +01:00
Vlad StanandGitHub 9ffc63b5dc fix: pay_invoice http error (#3506) 2025-11-12 09:22:49 +02:00
Tiago VasconcelosandGitHub 0ec8139e5c fix: vertically center first install (#3501) 2025-11-11 21:04:43 +01:00
dni ⚡andGitHub a8c3181852 fix: check_callback_url in dispatch_webhook was not handled (#3503) 2025-11-11 12:46:59 +02:00
e038ceb9be refactor: lndrest get_payment_status and pay_invoice use api v2 (#3470)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-11-10 16:26:14 +01:00
DoktorShiftandGitHub b4eccb9e5d Revise LNBits Admin UI documentation (#3443) 2025-11-10 16:08:33 +02:00
dni ⚡andGitHub 691b7ad055 refactor: lndgrpc, update grpcs file, use types and enums, LookupInvoiceV2 (#3469) 2025-11-10 15:07:00 +01:00
c84d1b66c6 fix: update API response structure and fix checking_id mismatch (#3478)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-11-10 16:05:50 +02:00
dni ⚡andGitHub cf7726ddfd refactor into '<lnbits-drawer />' component (#3499) 2025-11-10 14:54:06 +01:00
dni ⚡andGitHub bd50a3c546 refactor move home / into vue components (#3498) 2025-11-10 14:48:27 +01:00
dni ⚡andGitHub c9270bbf7f refactor into <lnbits-header /> component (#3488) 2025-11-10 13:56:32 +01:00
dni ⚡andGitHub 6474aeb982 refactor into <lnbits-footer /> component (#3487) 2025-11-10 11:53:20 +01:00
dni ⚡andGitHub 37ba437ad1 fix: incorrect styles in base.html from #3431 (#3486) 2025-11-10 11:48:09 +01:00
Vlad StanandGitHub 35af181e24 feat: migrate changes from PR #3456 (#3496) 2025-11-10 12:21:30 +02:00
dni ⚡andGitHub f8c58aef0e feat: move /first_install to vue component (#3485) 2025-11-10 11:17:29 +01:00
Vlad StanandGitHub 8755984bd8 [test] admin paths (#3494) 2025-11-10 12:00:55 +02:00
dni ⚡andGitHub 0d5661cda7 feat: move '/extensions' into vue component (#3480) 2025-11-10 10:20:18 +01:00
dni ⚡andGitHub c2a3fbc6c0 feat: move extension/builder into vue component (#3479) 2025-11-10 10:05:08 +01:00
dni ⚡andGitHub 6bac34fc6b refactor: use decorators for disabled endpoints (#3481) 2025-11-10 09:33:16 +01:00
Ben WeeksandGitHub b54eedee84 feat: Shared Wallets/Joint Accounts (Issue #3297) (#3376) 2025-11-07 22:25:03 +02:00
dni ⚡andGitHub bd07f7a5ef refactor: move /account into vue component (#3467) 2025-11-07 18:17:15 +01:00
dni ⚡andGitHub a40306f5cd refactor: move /admin into vue component (#3466) 2025-11-07 18:10:59 +01:00
dni ⚡andGitHub 2fecec2623 refactor: move /users into vue component (#3463) 2025-11-06 10:35:29 +01:00
dni ⚡andGitHub 4ad89396cc refactor: move /wallets into vue component (#3462) 2025-11-06 09:55:25 +01:00
dni ⚡andGitHub 82307c4839 refactor: move /audit into vue component (#3461) 2025-11-06 09:50:01 +01:00
dni ⚡andGitHub 8506199c2f refactor: move /node into vue component (#3460) 2025-11-06 09:45:41 +01:00
dni ⚡andGitHub d142d76148 refactor: simplify base.js (#3473) 2025-11-04 08:40:25 +01:00
dni ⚡andGitHub 98e9a61b98 fix: markdown rendering on index.html (#3472) 2025-11-04 08:40:10 +01:00
dni ⚡andGitHub 877af0c21e fix: broken html /docs use gfm (#3471) 2025-11-03 11:34:24 +01:00
dni ⚡andGitHub dd27b190f3 refactor: move /payments into vue component (#3414) 2025-10-31 08:15:01 +01:00
dni ⚡andGitHub 2856803ca7 fix: handle all lnurl exceptions on lnurlscan endpoint (#3451) 2025-10-30 08:07:25 +01:00
dni ⚡andGitHub ca8264b1f5 feat: remove nfc not supported (#3453) 2025-10-30 08:07:10 +01:00
dni ⚡andGitHub 39ca9da870 feat: NWC use coincurve instead of secp (#3455) 2025-10-30 08:06:31 +01:00
dni ⚡andGitHub 2b603bdc48 fix: add dependency-groups get rid of warning (#3454) 2025-10-29 15:59:31 +01:00
26780df065 nix: Re-enable and fix basic nix flake check for nix package and module (#3425)
Co-authored-by: Weston Keele <wekeele@proton.me>
2025-10-29 15:58:43 +01:00
dni ⚡andGitHub 248fcc06ab fix: release ci, pass upload_url from task to task (#3449) 2025-10-27 15:17:53 +02:00
Ben WeeksandGitHub f794373d30 feat: improve VoidWallet warning visibility on all devices (#3428) 2025-10-27 10:12:37 +02:00
Vlad StanandGitHub 87f25ea715 fix: LND GRPC macaroon fields (#3444) 2025-10-24 13:49:33 +03:00
Vlad StanandGitHub b9de754598 fix: exclude (soft) deleted wallets when creating an invoice (#3439) 2025-10-21 14:59:04 +03:00
785fb7af8e fix: improve mobile responsiveness for admin settings (#3431)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-21 13:54:49 +03:00
dni ⚡andGitHub 55c5ab3a6d feat: ci appimage have manual triggers (#3424) 2025-10-17 13:11:44 +01:00
275 changed files with 36647 additions and 21693 deletions
+7
View File
@@ -23,3 +23,10 @@ mypy.ini
package-lock.json
package.json
pytest.ini
.mypy_cache
.github
.pytest_cache
.vscode
bin
dist
-3
View File
@@ -18,7 +18,6 @@ ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
@@ -187,7 +186,6 @@ BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
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
@@ -333,4 +331,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
######################################
###### Logging and Development #######
######################################
+21 -2
View File
@@ -2,7 +2,26 @@ name: Build LNbits AppImage
on:
workflow_call:
inputs:
tag_name:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch:
inputs:
tag_name:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs:
build-linux-package:
@@ -75,7 +94,7 @@ jobs:
# Build AppImage
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 }}
TAG_NAME=${{ inputs.tag_name }}
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
./appimagetool-x86_64.AppImage \
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
@@ -93,7 +112,7 @@ jobs:
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ github.event.release.upload_url }}
upload_url: ${{ inputs.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
+15 -1
View File
@@ -12,6 +12,13 @@ jobs:
lint:
uses: ./.github/workflows/lint.yml
test-frontend:
needs: [ lint ]
uses: ./.github/workflows/make.yml
with:
make: test-frontend
npm: true
test-api:
needs: [ lint ]
strategy:
@@ -75,7 +82,14 @@ jobs:
strategy:
matrix:
python-version: ["3.10"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
backend-wallet-class:
- BoltzWallet
- LndRestWallet
- LndWallet
- CoreLightningWallet
- CoreLightningRestWallet
- LNbitsWallet
- EclairWallet
with:
custom-pytest: "uv run pytest tests/regtest"
python-version: ${{ matrix.python-version }}
+1
View File
@@ -62,3 +62,4 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
build-args: LNBITS_TAG=${{ inputs.tag }}
+4 -2
View File
@@ -40,8 +40,8 @@ jobs:
run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
chmod +x ./start-regtest
./start-regtest
sudo chmod -R a+rwx .
- name: Run pytest
@@ -63,6 +63,8 @@ jobs:
LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082
BOLTZ_CLIENT_ENDPOINT: 127.0.0.1:9002
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
ECLAIR_PASS: lnbits
+30
View File
@@ -10,7 +10,29 @@ permissions:
jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
@@ -19,6 +41,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
@@ -30,3 +53,10 @@ jobs:
uses: JRubics/poetry-publish@v1.15
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+16
View File
@@ -13,6 +13,8 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -21,8 +23,17 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
@@ -32,6 +43,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
@@ -41,6 +53,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
@@ -56,3 +69,6 @@ jobs:
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+1 -1
View File
@@ -1,4 +1,4 @@
name: tests
name: tests pytest
on:
workflow_call:
+1 -1
View File
@@ -23,7 +23,7 @@ repos:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/rbubley/mirrors-prettier
rev: v3.6.2
rev: v3.7.4
hooks:
- id: prettier
types_or: [css, javascript, html, json]
+3 -2
View File
@@ -1,6 +1,7 @@
FROM boltz/boltz-client:latest AS boltz
ARG LNBITS_TAG=latest
FROM lnbits/lnbits:latest
FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:${LNBITS_TAG}
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd
+4
View File
@@ -41,6 +41,10 @@ dev:
docker:
docker build -t lnbits/lnbits .
test-frontend:
npm install
npm test
test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
+4 -5
View File
@@ -5,8 +5,9 @@
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -77,13 +78,11 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=7c3aed&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
[docs]: https://github.com/lnbits/lnbits/wiki
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
+1
View File
@@ -4,6 +4,7 @@ color_scheme: dark
logo: "/logos/lnbits-full-inverse.png"
search_enabled: true
url: https://docs.lnbits.org
markdown: gfm
aux_links:
"LNbits on GitHub":
- "//github.com/lnbits/lnbits"
@@ -1,3 +1,9 @@
---
layout: default
title: FastAPI extension upgrade
nav_order: 1
---
## Defining a route with path parameters
**old:**
+34 -22
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Admin UI
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -12,11 +18,13 @@
# LNBits Admin UI
We introduced the Admin UI as the new default to make setup simpler and more straightforward. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
[What you can do](#what-you-can-do-with-the-admin-ui) · [First Run](#first-run-and-super-user-id) · [Enable/Disable](#enabling-or-disabling-the-admin-ui) · [Reset](#reset-to-defaults) · [Allowed Users](#allowed-users) · [Guides](#additional-guides)
**We introduced the Admin UI as the new default to make setup simpler and more straightforward**. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
<ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again.
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
For a complete reference of legacy variables consult **[.env.example](../../.env.example)**.
<img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" />
@@ -36,6 +44,27 @@ For a complete reference of legacy variables consult **[.env.example](../../.env
> [!NOTE]
> See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users.
## First run and Super User ID
On first start with the Admin UI enabled you will be prompted to generate a Super User.
<img width="1573" height="976" alt="Admin_UI_first_install" src="https://github.com/user-attachments/assets/05aa634f-06ec-4a4d-a5c6-d90927c90991" />
If you need to read it from disk later:
```bash
cat /lnbits/data/.super_user
# example
123de4bfdddddbbeb48c8bc8382fe123
```
> [!WARNING]
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
## Enabling or disabling the Admin UI
The Admin UI is enabled by default on new installs. To change the state:
@@ -76,23 +105,6 @@ The Admin UI is enabled by default on new installs. To change the state:
Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid.
## First run and Super User ID
On first start with the Admin UI enabled you will be prompted to generate a Super User. If you need to read it from disk later:
```bash
cat /lnbits/data/.super_user
# example
123de4bfdddddbbeb48c8bc8382fe123
```
> [!WARNING]
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
## Allowed Users
When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly.
@@ -105,8 +117,8 @@ When set **at least one**, LNbits becomes private: only the listed users and Adm
## Additional Guides
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
- **[User Roles](./User_Roles.md)** — Overview of existing roles in LNbits.
- **[Funding sources](./funding-sources_table.md)** — What is available and how to configure each.
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits.
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
@@ -115,7 +127,7 @@ LNbits empowers everyone with modular, open source tools for building Bitcoin ba
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
+6
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Extension Install
nav_order: 1
---
# Extension Install
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.
+7 -1
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Wallet comparison
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -72,7 +78,7 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
+33 -27
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Installation
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -51,8 +57,8 @@ LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most syst
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
> [!NOTE]
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2: UV (recommended for developers)
@@ -117,8 +123,8 @@ uv run lnbits
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> [!NOTE]
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2a (Legacy): Poetry — _Replaced by UV_
@@ -197,8 +203,8 @@ poetry run lnbits
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
@@ -217,8 +223,8 @@ chmod +x lnbits.sh &&
- You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2).
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
@@ -286,7 +292,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> **Next steps**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
@@ -340,8 +346,8 @@ docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
@@ -380,14 +386,14 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
> ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949)
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
> Environment variable strings should be quoted here. For example, if `.env` has
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
> Environment variable strings should be quoted here. For example, if `.env` has
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
> `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
> ![WARNING](https://img.shields.io/badge/WARNING-ea580c?labelColor=494949)
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
> Example (LND REST funding source):
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
> Example (LND REST funding source):
> `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`
```
@@ -445,8 +451,8 @@ poetry add setuptools wheel
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=0b0b0b)
>
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Troubleshooting
@@ -524,22 +530,22 @@ Visit **[http://localhost:5000/](http://localhost:5000/)** (or `0.0.0.0:5000`).
> [!NOTE]
> **Paths overview**
>
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
> Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user`
> - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs)
> - **Docker:** bind a host directory to `/app/data`.
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
> - **Docker:** bind a host directory to `/app/data`.
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
> The container reads `/app/.env` (usually bind-mounted from your project root).
> [!TIP]
> **Local Lightning test network**
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
> **Local Lightning test network**
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
> https://lightningpolar.com/
> [!TIP]
> **API comparison before updates**
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
> **API comparison before updates**
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
> https://arbadacarbayk.github.io/LNbits_TableTown/
# Additional guides
@@ -884,5 +890,5 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
+8 -2
View File
@@ -1,3 +1,9 @@
---
layout: default
title: Super user
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -110,8 +116,8 @@ These are practical tips for running a safe and friendly instance.
## Additional guides
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
- **[User Roles](./User_Roles.md)** — Overview of roles and what they can do.
- **[Funding sources](./funding-sources_table.md)** — Available options and how to enable and configure them.
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do.
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
+9 -3
View File
@@ -1,3 +1,9 @@
---
layout: default
title: User Roles
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
@@ -14,7 +20,7 @@
### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`.
**Jump to:**
**Jump to:**
[Roles at a Glance](#roles-at-a-glance) •
[Super User](#super-user--master-control) •
[Admin](#admin--day-to-day-manager) •
@@ -81,7 +87,7 @@ For using LNbits, not administering it.
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns.
- **[Funding sources](./funding-sources_table.md)** — Whats available and how to enable/configure each.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
@@ -89,7 +95,7 @@ LNbits empowers everyone with modular, open-source tools for building Bitcoin-ba
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
+2 -2
View File
@@ -354,8 +354,8 @@ Get more info here [https://docs.strike.me/strike-oauth-connect/](https://docs.s
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources_table.md)** — Whats available and how to enable/configure each.
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
+2 -1
View File
@@ -145,6 +145,7 @@
nixpkgs.overlays = [ self.overlays.${system}.default ];
};
checks = { };
checks =
import ./nix/tests { inherit pkgs; flake = self; };
});
}
+2
View File
@@ -1,5 +1,6 @@
from .core.services import create_invoice, pay_invoice
from .decorators import (
check_account_exists,
check_admin,
check_super_user,
check_user_exists,
@@ -11,6 +12,7 @@ from .exceptions import InvoiceError, PaymentError
__all__ = [
"InvoiceError",
"PaymentError",
"check_account_exists",
"check_admin",
"check_super_user",
"check_user_exists",
+2
View File
@@ -3,6 +3,7 @@ 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.asset_api import asset_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.callback_api import callback_router
@@ -44,6 +45,7 @@ def init_core_routers(app: FastAPI):
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(asset_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
+108
View File
@@ -0,0 +1,108 @@
from lnbits.core.db import db
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
from lnbits.db import Connection, Filters, Page
async def create_asset(
entry: Asset,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("assets", entry)
async def get_user_asset_info(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=AssetInfo,
)
async def get_asset_info(
asset_id: str, conn: Connection | None = None
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def get_user_asset(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=Asset,
)
async def get_public_asset(
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=Asset,
)
async def get_public_asset_info(
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def update_user_asset_info(
asset: AssetInfo,
) -> AssetInfo:
await db.update("assets", asset)
return asset
async def delete_user_asset(
user_id: str, asset_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
)
async def get_user_assets(
user_id: str,
filters: Filters[AssetFilters] | None = None,
conn: Connection | None = None,
) -> Page[AssetInfo]:
filters = filters or Filters()
filters.sortby = filters.sortby or "created_at"
return await (conn or db).fetch_page(
query="SELECT * FROM assets",
where=["user_id = :user_id"],
values={"user_id": user_id},
filters=filters,
model=AssetInfo,
table_name="assets",
)
async def get_user_assets_count(user_id: str) -> int:
result = await db.execute(
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
values={"user_id": user_id},
)
row = result.mappings().first()
return row.get("count", 0)
+2 -1
View File
@@ -16,11 +16,12 @@ async def get_audit_entries(
conn: Connection | None = None,
) -> Page[AuditEntry]:
return await (conn or db).fetch_page(
"SELECT * from audit",
"SELECT * FROM audit",
[],
{},
filters=filters,
model=AuditEntry,
table_name="audit",
)
+24 -12
View File
@@ -33,13 +33,12 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
async def get_standalone_payment(
checking_id_or_hash: str,
conn: Connection | None = None,
incoming: bool | None = False,
wallet_id: str | None = None,
conn: Connection | None = None,
) -> Payment | None:
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,
}
@@ -47,6 +46,10 @@ async def get_standalone_payment(
clause = f"({clause}) AND amount > 0"
if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
values["wallet_id"] = wallet.source_wallet_id
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
@@ -66,13 +69,16 @@ async def get_standalone_payment(
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Connection | None = None
) -> Payment | None:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
payment = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash
""",
{"wallet": wallet_id, "hash": payment_hash},
{"wallet": wallet.source_wallet_id, "hash": payment_hash},
Payment,
)
return payment
@@ -118,7 +124,6 @@ async def get_payments_paginated( # noqa: C901
Filters payments to be returned by:
- complete | pending | failed | outgoing | incoming.
"""
values: dict[str, Any] = {
"time": since,
}
@@ -128,7 +133,11 @@ async def get_payments_paginated( # noqa: C901
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
values["wallet_id"] = wallet_id
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return Page(data=[], total=0)
values["wallet_id"] = wallet.source_wallet_id
clause.append("wallet_id = :wallet_id")
elif user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
@@ -171,6 +180,7 @@ async def get_payments_paginated( # noqa: C901
values,
filters=filters,
model=Payment,
table_name="apipayments",
)
@@ -282,6 +292,7 @@ async def create_payment(
fee=-abs(data.fee),
tag=extra.get("tag", None),
extra=extra,
labels=data.labels or [],
)
await (conn or db).insert("apipayments", payment)
@@ -314,13 +325,14 @@ async def get_payments_history(
wallet_id: str | None = None,
group: DateTrunc = "day",
filters: Filters | None = None,
conn: Connection | None = None,
) -> list[PaymentHistoryPoint]:
if not filters:
filters = Filters()
date_trunc = db.datetime_grouping(group)
values = {
values: dict[str, Any] = {
"wallet_id": wallet_id,
}
# count outgoing payments if they are still pending
@@ -349,13 +361,13 @@ async def get_payments_history(
filters.values(values),
)
if wallet_id:
wallet = await get_wallet(wallet_id)
if wallet:
balance = wallet.balance_msat
else:
raise ValueError("Unknown wallet")
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return []
balance = wallet.balance_msat
values["wallet_id"] = wallet.source_wallet_id
else:
balance = await get_total_balance()
balance = await get_total_balance(conn=conn)
# since we dont know the balance at the starting point,
# we take the current balance and walk backwards
+21 -11
View File
@@ -4,7 +4,7 @@ from typing import Any
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.crud.wallets import create_wallet, get_wallets
from lnbits.core.db import db
from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page
@@ -30,9 +30,9 @@ async def create_account(
return account
async def update_account(account: Account) -> Account:
async def update_account(account: Account, conn: Connection | None = None) -> Account:
account.updated_at = datetime.now(timezone.utc)
await db.update("accounts", account)
await (conn or db).update("accounts", account)
return account
@@ -89,6 +89,7 @@ async def get_accounts(
filters=filters,
model=AccountOverview,
group_by=["accounts.id"],
table_name="accounts",
)
@@ -170,17 +171,24 @@ async def get_account_by_username_or_email(
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
account = await get_account(user_id, conn)
if not account:
return None
return await get_user_from_account(account, conn)
async with db.reuse_conn(conn) if conn else db.connect() as conn:
account = await get_account(user_id, conn=conn)
if not account:
return None
return await get_user_from_account(account, conn=conn)
async def get_user_from_account(
account: Account, conn: Connection | None = None
) -> User | None:
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
async with db.reuse_conn(conn) if conn else db.connect() as conn:
extensions = await get_user_active_extensions_ids(account.id, conn=conn)
wallets = await get_wallets(account.id, deleted=False, conn=conn)
if len(wallets) == 0:
wallet = await create_wallet(user_id=account.id, conn=conn)
wallets.append(wallet)
return User(
id=account.id,
email=account.email,
@@ -199,9 +207,11 @@ async def get_user_from_account(
)
async def update_user_access_control_list(user_acls: UserAcls):
async def update_user_access_control_list(
user_acls: UserAcls, conn: Connection | None = None
):
user_acls.updated_at = datetime.now(timezone.utc)
await db.update("accounts", user_acls)
await (conn or db).update("accounts", user_acls)
async def get_user_access_control_lists(
+105 -13
View File
@@ -3,9 +3,10 @@ from time import time
from uuid import uuid4
from lnbits.core.db import db
from lnbits.core.models.wallets import WalletsFilters
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType
from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings
from lnbits.utils.cache import cache
from ..models import Wallet
@@ -14,17 +15,22 @@ async def create_wallet(
*,
user_id: str,
wallet_name: str | None = None,
wallet_type: WalletType = WalletType.LIGHTNING,
shared_wallet_id: str | None = None,
conn: Connection | None = None,
) -> Wallet:
wallet_id = uuid4().hex
wallet = Wallet(
id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name,
wallet_type=wallet_type.value,
shared_wallet_id=shared_wallet_id,
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
@@ -46,6 +52,11 @@ async def delete_wallet(
conn: Connection | None = None,
) -> None:
now = int(time())
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
if cached_wallet:
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
@@ -103,8 +114,8 @@ async def delete_unused_wallets(
)
async def get_wallet(
wallet_id: str, deleted: bool | None = None, conn: Connection | None = None
async def get_standalone_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None:
query = """
SELECT *, COALESCE((
@@ -121,8 +132,23 @@ async def get_wallet(
)
async def get_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None:
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
return await get_source_wallet(wallet, conn)
return wallet
async def get_wallets(
user_id: str, deleted: bool | None = None, conn: Connection | None = None
user_id: str,
deleted: bool | None = False,
wallet_type: WalletType | None = None,
conn: Connection | None = None,
) -> list[Wallet]:
query = """
SELECT *, COALESCE((
@@ -132,12 +158,20 @@ async def get_wallets(
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchall(
if wallet_type is not None:
query += " AND wallet_type = :wallet_type "
wallets = await (conn or db).fetchall(
query,
{"user": user_id, "deleted": deleted},
{
"user": user_id,
"deleted": deleted,
"wallet_type": wallet_type.value if wallet_type else None,
},
Wallet,
)
return await get_source_wallets(wallets, conn)
async def get_wallets_paginated(
user_id: str,
@@ -149,7 +183,7 @@ async def get_wallets_paginated(
deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
return await (conn or db).fetch_page(
wallets = await (conn or db).fetch_page(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
@@ -159,20 +193,27 @@ async def get_wallets_paginated(
values={"user": user_id, "deleted": deleted},
filters=filters,
model=Wallet,
table_name="wallets",
)
wallets.data = await get_source_wallets(wallets.data, conn)
return wallets
async def get_wallets_ids(
user_id: str, deleted: bool | None = None, conn: Connection | None = None
user_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> list[str]:
query = """SELECT id FROM wallets WHERE "user" = :user"""
query = """SELECT * FROM wallets WHERE "user" = :user"""
if deleted is not None:
query += "AND deleted = :deleted"
result: list[dict] = await (conn or db).fetchall(
query += " AND deleted = :deleted "
wallets = await (conn or db).fetchall(
query,
{"user": user_id, "deleted": deleted},
Wallet,
)
return [row["id"] for row in result]
wallets = await get_source_wallets(wallets, conn)
return [w.source_wallet_id for w in wallets if w.can_view_payments]
async def get_wallets_count():
@@ -185,7 +226,7 @@ async def get_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> Wallet | None:
return await (conn or db).fetchone(
wallet = await (conn or db).fetchone(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
@@ -196,6 +237,57 @@ async def get_wallet_for_key(
{"key": key},
Wallet,
)
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
mw = await get_source_wallet(wallet, conn)
return mw
return wallet
async def get_base_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> BaseWallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT id, "user", wallet_type, adminkey, inkey FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
""",
{"key": key},
BaseWallet,
)
if not wallet:
return None
return wallet
async def get_source_wallet(
wallet: Wallet, conn: Connection | None = None
) -> Wallet | None:
if not wallet.is_lightning_shared_wallet:
return wallet
if not wallet.shared_wallet_id:
return None
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
if not shared_wallet:
return None
wallet.mirror_shared_wallet(shared_wallet)
return wallet
async def get_source_wallets(
wallet: list[Wallet], conn: Connection | None = None
) -> list[Wallet]:
source_wallets = []
for w in wallet:
source_wallet = await get_source_wallet(w, conn)
if source_wallet:
source_wallets.append(source_wallet)
return source_wallets
async def get_total_balance(conn: Connection | None = None):
+105
View File
@@ -743,3 +743,108 @@ async def m034_add_stored_paylinks_to_wallet(db: Connection):
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
"""
)
async def m035_add_wallet_type_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
"""
)
async def m036_add_shared_wallet_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
"""
)
async def m037_create_assets_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
mime_type TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT false,
name TEXT NOT NULL,
size_bytes INT NOT NULL,
thumbnail_base64 TEXT,
thumbnail {db.blob},
data {db.blob} NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m038_add_labels_for_payments(db: Connection):
await db.execute(
"""
ALTER TABLE apipayments ADD COLUMN labels TEXT
"""
)
async def m039_index_payments(db: Connection):
indexes = [
"wallet_id",
"checking_id",
"payment_hash",
"amount",
"fee",
"labels",
"time",
"status",
"memo",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_payments_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
"""
)
async def m040_index_wallets(db: Connection):
indexes = [
"id",
"user",
"deleted",
"adminkey",
"inkey",
"wallet_type",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
"""
)
async def m042_index_accounts(db: Connection):
indexes = [
"id",
"email",
"username",
"pubkey",
"external_id",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
"""
)
+2 -2
View File
@@ -46,7 +46,7 @@ from .users import (
UserAcls,
UserExtra,
)
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .wallets import CreateWallet, KeyType, Wallet, WalletInfo, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [
@@ -57,7 +57,6 @@ __all__ = [
"AuditEntry",
"AuditFilters",
"BalanceDelta",
"BaseWallet",
"Callback",
"CancelInvoice",
"ConversionData",
@@ -99,6 +98,7 @@ __all__ = [
"UserAcls",
"UserExtra",
"Wallet",
"WalletInfo",
"WalletTypeInfo",
"WebPushSubscription",
]
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
class AssetInfo(BaseModel):
id: str
mime_type: str
name: str
is_public: bool = False
size_bytes: int
thumbnail_base64: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Asset(AssetInfo):
user_id: str
data: bytes
class AssetUpdate(BaseModel):
name: str | None = None
is_public: bool | None = None
class AssetFilters(FilterModel):
__search_fields__ = ["name"]
__sort_fields__ = [
"created_at",
"name",
]
name: str | None = None
+33
View File
@@ -6,6 +6,7 @@ import json
import os
import shutil
import zipfile
from asyncio.tasks import create_task
from pathlib import Path
from typing import Any
@@ -20,6 +21,7 @@ from lnbits.helpers import (
version_parse,
)
from lnbits.settings import settings
from lnbits.utils.cache import cache
class ExplicitRelease(BaseModel):
@@ -606,6 +608,37 @@ class InstallableExtension(BaseModel):
@classmethod
async def get_installable_extensions(
cls, post_refresh_cache: bool = False
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
cache_key = "extensions:installable"
cache_value = cache.value(cache_key)
if not cache_value:
extension_list = await cls._get_installable_extensions()
cache.set(cache_key, extension_list, expiry=3600) # one hour
return extension_list
if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested
create_task(cls._refresh_installable_extensions_cache())
extension_list = cache_value.value # type: ignore
return extension_list
@classmethod
async def _refresh_installable_extensions_cache(
cls,
) -> None:
cache_key = "extensions:installable"
extension_list: list[InstallableExtension] = (
await cls._get_installable_extensions()
)
cache.set(cache_key, extension_list, expiry=3600)
@classmethod
async def _get_installable_extensions(
cls,
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
+26 -2
View File
@@ -59,6 +59,7 @@ class CreatePayment(BaseModel):
expiry: datetime | None = None
webhook: str | None = None
fee: int = 0
labels: list[str] | None = None
class Payment(BaseModel):
@@ -81,6 +82,7 @@ class Payment(BaseModel):
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))
labels: list[str] = []
extra: dict = {}
def __init__(self, **data):
@@ -184,9 +186,25 @@ class Payment(BaseModel):
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
__search_fields__ = [
"memo",
"amount",
"wallet_id",
"tag",
"status",
"time",
"labels",
]
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
__sort_fields__ = [
"created_at",
"updated_at",
"amount",
"fee",
"memo",
"time",
"tag",
]
status: str | None
tag: str | None
@@ -198,6 +216,7 @@ class PaymentFilters(FilterModel):
preimage: str | None
payment_hash: str | None
wallet_id: str | None
labels: str | None
class PaymentDataPoint(BaseModel):
@@ -272,6 +291,7 @@ class CreateInvoice(BaseModel):
bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
labels: list[str] = []
@validator("payment_hash")
def check_hex(cls, v):
@@ -320,3 +340,7 @@ class CancelInvoice(BaseModel):
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []
+87 -8
View File
@@ -12,6 +12,7 @@ from lnbits.db import FilterModel
from lnbits.helpers import (
is_valid_email_address,
is_valid_external_id,
is_valid_label,
is_valid_pubkey,
is_valid_username,
)
@@ -29,6 +30,21 @@ class UserNotifications(BaseModel):
incoming_payments_sats: int = 0
class WalletInviteRequest(BaseModel):
request_id: str
from_user_name: str | None = None
to_wallet_id: str
to_wallet_name: str
class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250)
color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
class UserExtra(BaseModel):
email_verified: bool | None = False
first_name: str | None = None
@@ -46,6 +62,55 @@ class UserExtra(BaseModel):
notifications: UserNotifications = UserNotifications()
wallet_invite_requests: list[WalletInviteRequest] = []
labels: list[UserLabel] = []
def add_wallet_invite_request(
self,
request_id: str,
to_wallet_id: str,
to_wallet_name: str,
from_user_name: str | None = None,
) -> WalletInviteRequest:
self.remove_wallet_invite_request(request_id)
invite = WalletInviteRequest(
request_id=request_id,
from_user_name=from_user_name,
to_wallet_id=to_wallet_id,
to_wallet_name=to_wallet_name,
)
self.wallet_invite_requests.append(invite)
return invite
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
for invite in self.wallet_invite_requests:
if invite.request_id == request_id:
return invite
return None
def validate_labels(self):
seen_labels = set()
for label in self.labels:
if not label.name:
raise ValueError("Label name cannot be empty.")
# apply the same rule for labels as for usernames
if not is_valid_label(label.name):
raise ValueError(f"Invalid label name: {label.name}")
if label.name in seen_labels:
raise ValueError(f"Duplicate label name: {label.name}")
seen_labels.add(label.name)
def remove_wallet_invite_request(
self,
request_id: str,
):
self.wallet_invite_requests = [
invite
for invite in self.wallet_invite_requests
if invite.request_id != request_id
]
class EndpointAccess(BaseModel):
path: str
@@ -107,8 +172,15 @@ class UserAcls(BaseModel):
return None
class Account(BaseModel):
class AccountId(BaseModel):
id: str
@property
def is_admin_id(self) -> bool:
return settings.is_admin_user(self.id)
class Account(AccountId):
external_id: str | None = None # for external account linking
username: str | None = None
password_hash: str | None = None
@@ -129,6 +201,10 @@ class Account(BaseModel):
self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
@property
def has_password(self) -> bool:
return self.password_hash is not None
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
salt = gensalt()
@@ -160,6 +236,8 @@ class Account(BaseModel):
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
self.extra.validate_labels()
class AccountOverview(Account):
transaction_count: int | None = 0
@@ -170,7 +248,7 @@ class AccountOverview(Account):
class AccountFilters(FilterModel):
__search_fields__ = [
"user",
"id",
"email",
"username",
"pubkey",
@@ -178,17 +256,18 @@ class AccountFilters(FilterModel):
"wallet_id",
]
__sort_fields__ = [
"balance_msat",
"id",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
"pubkey",
"external_id",
"created_at",
"updated_at",
]
email: str | None = None
user: str | None = None
id: str | None = None
username: str | None = None
email: str | None = None
pubkey: str | None = None
external_id: str | None = None
wallet_id: str | None = None
+172 -11
View File
@@ -4,16 +4,14 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from lnurl import encode as lnurl_encode
from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.settings import settings
class BaseWallet(BaseModel):
class WalletInfo(BaseModel):
id: str
name: str
adminkey: str
@@ -21,18 +19,109 @@ class BaseWallet(BaseModel):
balance_msat: int
class WalletType(Enum):
LIGHTNING = "lightning"
LIGHTNING_SHARED = "lightning-shared"
class WalletPermission(Enum):
VIEW_PAYMENTS = "view-payments"
RECEIVE_PAYMENTS = "receive-payments"
SEND_PAYMENTS = "send-payments"
def __str__(self):
return self.value
class WalletShareStatus(Enum):
INVITE_SENT = "invite_sent"
APPROVED = "approved"
class WalletSharePermission(BaseModel):
# unique identifier for this share request
request_id: str | None = None
# username of the invited user
username: str
# ID of the wallet being shared with
shared_with_wallet_id: str | None = None
# permissions being granted
permissions: list[WalletPermission] = []
# status of the share request
status: WalletShareStatus
comment: str | None = None
def approve(
self,
permissions: list[WalletPermission] | None = None,
shared_with_wallet_id: str | None = None,
):
self.status = WalletShareStatus.APPROVED
if permissions is not None:
self.permissions = permissions
if shared_with_wallet_id is not None:
self.shared_with_wallet_id = shared_with_wallet_id
@property
def is_approved(self) -> bool:
return self.status == WalletShareStatus.APPROVED
class WalletExtra(BaseModel):
icon: str = "flash_on"
color: str = "primary"
pinned: bool = False
# What permissions this wallet grants when it's shared with other users
shared_with: list[WalletSharePermission] = []
def invite_user_to_shared_wallet(
self,
request_id: str,
request_type: WalletShareStatus,
username: str,
permissions: list[WalletPermission] | None = None,
) -> WalletSharePermission:
share = WalletSharePermission(
request_id=request_id,
username=username,
status=request_type,
permissions=permissions or [],
)
self.shared_with.append(share)
return share
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
for share in self.shared_with:
if share.request_id == request_id:
return share
return None
def find_share_for_wallet(
self, shared_with_wallet_id: str
) -> WalletSharePermission | None:
for share in self.shared_with:
if share.shared_with_wallet_id == shared_with_wallet_id:
return share
return None
def remove_share_by_id(self, request_id: str):
self.shared_with = [
share for share in self.shared_with if share.request_id != request_id
]
class Wallet(BaseModel):
class BaseWallet(BaseModel):
id: str
user: str
name: str
wallet_type: str = WalletType.LIGHTNING.value
adminkey: str
inkey: str
class Wallet(BaseWallet):
name: str
# Must be set only for shared wallets
shared_wallet_id: str | None = None
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))
@@ -40,6 +129,65 @@ class Wallet(BaseModel):
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
# What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
def __init__(self, **data):
super().__init__(**data)
self._validate_data()
def mirror_shared_wallet(
self,
shared_wallet: Wallet,
):
if not shared_wallet.is_lightning_wallet:
return None
self.wallet_type = WalletType.LIGHTNING_SHARED.value
self.shared_wallet_id = shared_wallet.id
self.name = shared_wallet.name
self.share_permissions = shared_wallet.get_share_permissions(self.id)
if len(self.share_permissions):
self.currency = shared_wallet.currency
self.balance_msat = shared_wallet.balance_msat
self.stored_paylinks = shared_wallet.stored_paylinks
self.extra.icon = shared_wallet.extra.icon
self.extra.color = shared_wallet.extra.color
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
for share in self.extra.shared_with:
if share.shared_with_wallet_id == wallet_id and share.is_approved:
return share.permissions
return []
def has_permission(self, permission: WalletPermission) -> bool:
if self.is_lightning_wallet:
return True
if self.is_lightning_shared_wallet:
return permission in self.share_permissions
return False
@property
def source_wallet_id(self) -> str:
"""For shared wallets return the original wallet ID, else return own ID."""
if self.is_lightning_shared_wallet and len(self.share_permissions):
return self.shared_wallet_id or self.id
return self.id
@property
def can_receive_payments(self) -> bool:
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
@property
def can_send_payments(self) -> bool:
return self.has_permission(WalletPermission.SEND_PAYMENTS)
@property
def can_view_payments(self) -> bool:
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
@property
def balance(self) -> int:
@@ -50,16 +198,23 @@ class Wallet(BaseModel):
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 is_lightning_wallet(self) -> bool:
return self.wallet_type == WalletType.LIGHTNING.value
@property
def is_lightning_shared_wallet(self) -> bool:
return self.wallet_type == WalletType.LIGHTNING_SHARED.value
def _validate_data(self):
if self.is_lightning_shared_wallet:
if not self.shared_wallet_id:
raise ValueError("Shared wallet ID must be set for shared wallets.")
class CreateWallet(BaseModel):
name: str | None = None
wallet_type: WalletType = WalletType.LIGHTNING
shared_wallet_id: str | None = None
class KeyType(Enum):
@@ -78,6 +233,12 @@ class WalletTypeInfo:
wallet: Wallet
@dataclass
class BaseWalletTypeInfo:
key_type: KeyType
wallet: BaseWallet
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
+56
View File
@@ -0,0 +1,56 @@
import base64
import io
from uuid import uuid4
from fastapi import UploadFile
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset
from lnbits.settings import settings
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type:
raise ValueError("File must have a content type.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id):
user_assets_count = await get_user_assets_count(user_id)
if user_assets_count >= settings.lnbits_max_assets_per_user:
raise ValueError(
f"Max upload count of {settings.lnbits_max_assets_per_user} exceeded."
)
contents = await file.read()
if len(contents) > settings.lnbits_max_asset_size_mb * 1024 * 1024:
raise ValueError(
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
asset = Asset(
id=uuid4().hex,
user_id=user_id,
mime_type=file.content_type,
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
data=contents,
)
await create_asset(asset)
return asset
+3 -2
View File
@@ -16,6 +16,7 @@ from lnbits.core.crud.extensions import (
update_installed_extension,
)
from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection
from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
@@ -149,9 +150,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
async def get_valid_extensions(
include_deactivated: bool | None = True,
include_deactivated: bool | None = True, conn: Connection | None = None
) -> list[Extension]:
installed_extensions = await get_installed_extensions()
installed_extensions = await get_installed_extensions(conn=conn)
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
if include_deactivated:
+59
View File
@@ -1,7 +1,9 @@
import hashlib
import hmac
import json
import time
import httpx
from loguru import logger
from lnbits.core.crud import get_wallet
@@ -70,6 +72,63 @@ def check_stripe_signature(
raise ValueError("Stripe signature verification failed.")
async def verify_paypal_webhook(headers, payload: bytes):
"""
Validate PayPal webhook signatures using the PayPal verify API.
"""
webhook_id = settings.paypal_webhook_id
if not webhook_id:
logger.warning("PayPal webhook ID not set; skipping verification.")
return
required_headers = {
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
"PAYPAL-TRANSMISSION-TIME": headers.get("PAYPAL-TRANSMISSION-TIME"),
"PAYPAL-TRANSMISSION-SIG": headers.get("PAYPAL-TRANSMISSION-SIG"),
"PAYPAL-CERT-URL": headers.get("PAYPAL-CERT-URL"),
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
}
if not all(required_headers.values()):
logger.warning("Missing PayPal webhook headers; skipping verification.")
return
try:
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
token_resp = await client.post(
"/v1/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(
settings.paypal_client_id or "",
settings.paypal_client_secret or "",
),
)
token_resp.raise_for_status()
access_token = token_resp.json().get("access_token")
if not access_token:
raise ValueError("PayPal token missing in verification flow.")
verify_resp = await client.post(
"/v1/notifications/verify-webhook-signature",
json={
"auth_algo": required_headers["PAYPAL-AUTH-ALGO"],
"cert_url": required_headers["PAYPAL-CERT-URL"],
"transmission_id": required_headers["PAYPAL-TRANSMISSION-ID"],
"transmission_sig": required_headers["PAYPAL-TRANSMISSION-SIG"],
"transmission_time": required_headers["PAYPAL-TRANSMISSION-TIME"],
"webhook_id": webhook_id,
"webhook_event": json.loads(payload.decode()),
},
headers={"Authorization": f"Bearer {access_token}"},
)
verify_resp.raise_for_status()
verification_status = verify_resp.json().get("verification_status")
if verification_status != "SUCCESS":
raise ValueError("PayPal webhook verification failed.")
except Exception as exc:
logger.warning(exc)
raise ValueError("PayPal webhook cannot be verified.") from exc
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
+2 -1
View File
@@ -70,7 +70,8 @@ async def check_balance_delta_changed():
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:
delta_change = abs(status.delta_sats - settings.latest_balance_delta_sats)
if delta_change >= settings.notification_balance_delta_threshold_sats:
enqueue_admin_notification(
NotificationType.balance_delta,
{
+20 -1
View File
@@ -1,6 +1,7 @@
import asyncio
import json
import smtplib
from asyncio.tasks import create_task
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from http import HTTPStatus
@@ -16,6 +17,7 @@ from lnbits.core.crud import (
mark_webhook_sent,
)
from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
@@ -240,6 +242,10 @@ async def dispatch_webhook(payment: Payment):
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(payment.webhook)
except ValueError as exc:
await mark_webhook_sent(payment.payment_hash, "-1")
logger.warning(f"Invalid webhook URL {payment.webhook}: {exc!s}")
try:
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))
@@ -257,6 +263,12 @@ async def dispatch_webhook(payment: Payment):
async def send_payment_notification(wallet: Wallet, payment: Payment):
try:
await send_ws_payment_notification(wallet, payment)
for shared in wallet.extra.shared_with:
if not shared.shared_with_wallet_id:
continue
shared_wallet = await get_wallet(shared.shared_with_wallet_id)
if shared_wallet and shared_wallet.can_view_payments:
await send_ws_payment_notification(shared_wallet, payment)
except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}")
try:
@@ -275,6 +287,13 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
logger.error(f"Error dispatching webhook: {e!s}")
def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
try:
create_task(send_payment_notification(wallet, payment))
except Exception as e:
logger.warning(f"Error sending payment notification: {e}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json())
@@ -381,7 +400,7 @@ def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
return settings.notification_balance_delta_threshold_sats > 0
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
+54 -27
View File
@@ -47,7 +47,7 @@ from ..models import (
PaymentState,
Wallet,
)
from .notifications import send_payment_notification
from .notifications import send_payment_notification_in_background
payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, asyncio.Lock] = {}
@@ -61,11 +61,13 @@ async def pay_invoice(
extra: dict | None = None,
description: str = "",
tag: str = "",
labels: list[str] | None = None,
conn: Connection | None = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed")
invoice = _validate_payment_request(payment_request, max_sat)
if not invoice.amount_msat:
raise ValueError("Missig invoice amount.")
@@ -73,25 +75,34 @@ async def pay_invoice(
amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
if not wallet.can_send_payments:
raise PaymentError(
"Wallet does not have permission to pay invoices.",
status="failed",
)
if await is_internal_status_success(invoice.payment_hash, new_conn):
raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
wallet_id=wallet.source_wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=-amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
labels=labels,
)
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(wallet, payment, new_conn)
payment = await _pay_invoice(
wallet.source_wallet_id, create_payment_model, conn=new_conn
)
await _credit_service_fee_wallet(wallet, payment, conn=new_conn)
return payment
@@ -190,19 +201,22 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
# do not save memo if description_hash or unhashed_description is set
memo = ""
payment = await create_invoice(
wallet_id=wallet_id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
)
async with db.connect() as conn:
payment = await create_invoice(
wallet_id=wallet_id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
labels=data.labels,
conn=conn,
)
if data.lnurl_withdraw:
try:
@@ -241,6 +255,7 @@ async def create_invoice(
webhook: str | None = None,
internal: bool | None = False,
payment_hash: str | None = None,
labels: list[str] | None = None,
conn: Connection | None = None,
) -> Payment:
if not amount > 0:
@@ -250,6 +265,12 @@ async def create_invoice(
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
if not user_wallet.can_receive_payments:
raise InvoiceError(
"Wallet does not have permission to create invoices.",
status="failed",
)
invoice_memo = None if description_hash else memo[:640]
# use the fake wallet if the invoice is for internal use only
@@ -308,7 +329,7 @@ async def create_invoice(
invoice = bolt11_decode(invoice_response.payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
wallet_id=user_wallet.source_wallet_id,
bolt11=invoice_response.payment_request,
payment_hash=invoice.payment_hash,
preimage=invoice_response.preimage,
@@ -318,6 +339,7 @@ async def create_invoice(
extra=extra,
webhook=webhook,
fee=invoice_response.fee_msat or 0,
labels=labels,
)
payment = await create_payment(
@@ -339,13 +361,15 @@ async def update_pending_payments(wallet_id: str):
await update_pending_payment(payment)
async def update_pending_payment(payment: Payment) -> Payment:
async def update_pending_payment(
payment: Payment, conn: Connection | None = None
) -> Payment:
status = await payment.check_status()
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
await update_payment(payment, conn=conn)
elif status.success:
payment = await update_payment_success_status(payment, status)
payment = await update_payment_success_status(payment, status, conn=conn)
return payment
@@ -456,7 +480,7 @@ async def update_wallet_balance(
await create_payment(
checking_id=f"internal_{payment_hash}",
data=CreatePayment(
wallet_id=wallet.id,
wallet_id=wallet.source_wallet_id,
bolt11=bolt11,
payment_hash=payment_hash,
amount_msat=amount * 1000,
@@ -475,7 +499,7 @@ async def update_wallet_balance(
raise ValueError("Balance change failed, amount exceeds maximum balance.")
async with db.reuse_conn(conn) if conn else db.connect() as conn:
payment = await create_invoice(
wallet_id=wallet.id,
wallet_id=wallet.source_wallet_id,
amount=amount,
memo="Admin credit",
internal=True,
@@ -682,6 +706,7 @@ async def _pay_internal_invoice(
internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn
)
if not internal_payment:
return None
@@ -690,6 +715,7 @@ async def _pay_internal_invoice(
internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn
)
if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed")
@@ -711,6 +737,7 @@ async def _pay_internal_invoice(
internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment(
checking_id=internal_id,
data=create_payment_model,
@@ -725,7 +752,7 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}")
await send_payment_notification(wallet, payment)
send_payment_notification_in_background(wallet, payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
@@ -798,7 +825,7 @@ async def _pay_external_invoice(
payment = await update_payment_success_status(
payment, payment_response, conn=conn
)
await send_payment_notification(wallet, payment)
send_payment_notification_in_background(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id
@@ -910,7 +937,7 @@ async def _credit_service_fee_wallet(
memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
Wallet: '{wallet.name}' ({wallet.source_wallet_id})."""
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
+32 -24
View File
@@ -3,7 +3,9 @@ from uuid import uuid4
from loguru import logger
from lnbits.core.db import db
from lnbits.core.models.extensions import UserExtension
from lnbits.db import Connection
from lnbits.settings import (
EditableSettings,
SuperSettings,
@@ -48,37 +50,43 @@ async def create_user_account_no_ckeck(
account: Account | None = None,
wallet_name: str | None = None,
default_exts: list[str] | None = None,
conn: Connection | None = None,
) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
if account:
account.validate_fields()
if account.username and await get_account_by_username(
account.username, conn=conn
):
raise ValueError("Username already exists.")
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, conn=conn):
raise ValueError("Email 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, conn=conn
):
raise ValueError("Pubkey 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
if not account.id:
account.id = uuid4().hex
account = await create_account(account, conn=conn)
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
conn=conn,
)
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, conn=conn)
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
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)
user = await get_user_from_account(account, conn=conn)
if not user:
raise ValueError("Cannot find user for account.")
+202
View File
@@ -0,0 +1,202 @@
from lnbits.core.crud.users import (
get_account,
get_account_by_username_or_email,
update_account,
)
from lnbits.core.crud.wallets import (
create_wallet,
force_delete_wallet,
get_standalone_wallet,
get_wallet,
get_wallets,
update_wallet,
)
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import Account
from lnbits.core.models.wallets import (
Wallet,
WalletSharePermission,
WalletShareStatus,
WalletType,
)
from lnbits.db import Connection
from lnbits.helpers import sha256s
async def invite_to_wallet(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.username:
raise ValueError("Username or email missing.")
invited_user = await get_account_by_username_or_email(data.username)
if not invited_user:
raise ValueError("Invited user not found.")
request_id = sha256s(invited_user.id + source_wallet.id)
share = source_wallet.extra.find_share_by_id(request_id)
if share:
raise ValueError("User already invited to this wallet.")
invite_request = source_wallet.extra.invite_user_to_shared_wallet(
request_id=request_id,
request_type=WalletShareStatus.INVITE_SENT,
username=data.username,
permissions=data.permissions,
)
await update_wallet(source_wallet)
wallet_owner = await get_account(source_wallet.user)
if not wallet_owner:
raise ValueError("Cannot find wallet owner.")
invited_user.extra.add_wallet_invite_request(
request_id=request_id,
from_user_name=wallet_owner.username or wallet_owner.email,
to_wallet_id=source_wallet.id,
to_wallet_name=source_wallet.name,
)
await update_account(invited_user)
return invite_request
async def reject_wallet_invitation(invited_user_id: str, share_request_id: str):
invited_user = await get_account(invited_user_id)
if not invited_user:
raise ValueError("Invited user not found.")
existing_request = invited_user.extra.find_wallet_invite_request(share_request_id)
if not existing_request:
raise ValueError("Invitation not found.")
invited_user.extra.remove_wallet_invite_request(share_request_id)
await update_account(invited_user)
async def update_wallet_share_permissions(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.shared_with_wallet_id:
raise ValueError("Wallet ID missing.")
share = source_wallet.extra.find_share_for_wallet(data.shared_with_wallet_id)
if not share:
raise ValueError("Share not found")
if not share.shared_with_wallet_id:
raise ValueError("Share does not have a mirror wallet ID.")
mirror_wallet = await get_wallet(share.shared_with_wallet_id)
if not mirror_wallet:
raise ValueError("Target wallet not found")
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
share.approve(permissions=data.permissions)
await update_wallet(source_wallet)
return share
async def delete_wallet_share(source_wallet: Wallet, request_id: str) -> SimpleStatus:
if not source_wallet.is_lightning_wallet:
raise ValueError("Source wallet is not a lightning wallet.")
share = source_wallet.extra.find_share_by_id(request_id)
if not share:
raise ValueError("Wallet share not found.")
source_wallet.extra.remove_share_by_id(request_id)
invited_user = await get_account_by_username_or_email(share.username)
if not invited_user:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Invited user not found."
)
if invited_user.extra.find_wallet_invite_request(request_id):
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
mirror_wallets = await get_wallets(
invited_user.id, wallet_type=WalletType.LIGHTNING_SHARED
)
mirror_wallet = next(
(w for w in mirror_wallets if w.shared_wallet_id == source_wallet.id), None
)
if not mirror_wallet:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Target wallet not found."
)
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared lightning wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
await force_delete_wallet(mirror_wallet.id)
await update_wallet(source_wallet)
return SimpleStatus(success=True, message="Permission removed.")
async def create_lightning_shared_wallet(
user_id: str,
source_wallet_id: str,
conn: Connection | None = None,
) -> Wallet:
source_wallet = await get_standalone_wallet(source_wallet_id, conn=conn)
if not source_wallet:
raise ValueError("Shared wallet does not exist.")
if not source_wallet.is_lightning_wallet:
raise ValueError("Shared wallet is not a lightning wallet.")
if source_wallet.user == user_id:
raise ValueError("Cannot mirror your own wallet.")
invited_user = await get_account(user_id, conn=conn)
if not invited_user:
raise ValueError("Cannot find invited user.")
return await _accept_invitation_to_shared_wallet(
invited_user, source_wallet, conn=conn
)
async def _accept_invitation_to_shared_wallet(
invited_user: Account,
source_wallet: Wallet,
conn: Connection | None = None,
) -> Wallet:
request_id = sha256s(invited_user.id + source_wallet.id)
existing_request = source_wallet.extra.find_share_by_id(request_id)
if not existing_request:
raise ValueError("No invitation found for this invited user.")
if existing_request.status == WalletShareStatus.APPROVED:
raise ValueError("This wallet is already shared with you.")
if existing_request.status != WalletShareStatus.INVITE_SENT:
raise ValueError("Unknown request type.")
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
# todo: double check if user already has a mirror wallet for this source wallet
mirror_wallet = await create_wallet(
user_id=invited_user.id,
wallet_name=source_wallet.name,
wallet_type=WalletType.LIGHTNING_SHARED,
shared_wallet_id=source_wallet.id,
conn=conn,
)
existing_request.approve(shared_with_wallet_id=mirror_wallet.id)
await update_wallet(source_wallet, conn=conn)
mirror_wallet.mirror_shared_wallet(source_wallet)
return mirror_wallet
+4 -2
View File
@@ -35,7 +35,7 @@ async def run_by_the_minute_tasks() -> None:
while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60
if settings.notification_balance_delta_changed:
if settings.notification_balance_delta_threshold_sats > 0:
try:
# runs by default every minute, the delta should not change that often
await check_balance_delta_changed()
@@ -57,7 +57,9 @@ async def run_by_the_minute_tasks() -> None:
if minute_counter % 60 == 0:
try:
# initialize the list of all extensions
await InstallableExtension.get_installable_extensions()
await InstallableExtension.get_installable_extensions(
post_refresh_cache=True
)
except Exception as ex:
logger.error(ex)
@@ -1,197 +0,0 @@
<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>
@@ -1,287 +0,0 @@
<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.completed</code></li>
- the user completed the checkout process
<li><code>invoice.paid</code></li>
- the invoice was successfully paid (for subscriptions)
</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>
@@ -1,67 +0,0 @@
<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>
-251
View File
@@ -1,251 +0,0 @@
{% 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">
<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-badge
v-if="checkChanges"
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-badge
v-if="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</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>
<q-splitter>
<template v-slot:before>
<q-tabs
@update:model-value="showExchangeProvidersTab"
v-model="tab"
vertical
active-color="primary"
>
<q-tab
name="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="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="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>
</template>
<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>
<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
@@ -1,201 +0,0 @@
{% 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 %}
-305
View File
@@ -1,305 +0,0 @@
<q-expansion-item
group="extras"
icon="vpn_key"
:label="$t('api_keys_api_docs')"
:content-inset-level="0.5"
>
<q-card-section>
<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"
:show-buttons="false"
></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"
:show-buttons="false"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-expansion-item
group="api"
dense
expand-separator
label="Get wallet details"
>
<q-card>
<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
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "name": &lt;string&gt;, "balance":
&lt;int&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<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
expand-separator
label="Create an invoice (incoming)"
>
<q-card>
<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
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;,
"expiry": &lt;int&gt;, "unit": &lt;string&gt;, "webhook":
&lt;url:string&gt;, "internal": &lt;bool&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<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>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Pay an invoice (outgoing)"
>
<q-card>
<q-card-section>
<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": "<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">
Returns 201 CREATED (application/json)
</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<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>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Decode an invoice"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<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 <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>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check an invoice (incoming or outgoing)"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<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
<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
@@ -1,830 +0,0 @@
{% 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">
<div class="col-12">
<q-stepper
v-model="step"
ref="stepper"
color="primary"
animated
header-nav
class="q-pt-sm"
@update:model-value="onStepChange"
>
<q-step
:name="1"
title="Describe"
icon="info"
:done="step > 1"
style="min-height: 100px"
>
<div class="row q-col-gutter-md">
<div class="col-12">
<span class="text-h6">
Tell us something about your extension:
</span>
<ul>
<li>This is the first step, you can return and change it.</li>
<li>
The <code>`name`</code> and
<code>`sort description`</code> fields are what the users will
see when browsing the list of extensions.
</li>
<li>
The <code>`id`</code> field is used internally and in the URL of
your extension.
</li>
</ul>
</div>
<!-- todo: add icon -->
<div class="col-12">
<div>
<q-btn
color="primary"
label="Upload Existing config"
@click="$refs.extensionDataInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="extensionDataInput"
accept="application/json"
style="display: none"
@change="onJsonDataInput"
/>
</div>
</div>
</div>
<q-separator class="q-mt-sm"></q-separator>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.name"
label="Extension Name"
hint="The name of your extension"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.id"
label="Extension Id"
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.short_description"
label="Short Description"
hint="A short description that is shown in the extension list."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<q-input
filled
v-model="extensionData.description"
label="Description"
hint="A detailed description of your extension."
type="textarea"
rows="3"
maxlength="1000"
>
</q-input>
</div>
</div>
</q-step>
<q-step
:name="2"
title="Settings"
icon="settings"
:done="step > 2"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep2"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('settings')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.settings_data.enabled"
label="Generate Settings Fields"
size="md"
color="green"
/>
<br />
<ul>
<li>Define what settings your extension will have.</li>
<li>
You can choose if each user has its own settings or if the
settings are global (set by the admin).
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.settings_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-md-2 col-sm-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.settings_data.type"
:options="settingsTypes"
></q-select>
</div>
<div class="col-md-10 col-sm-12 q-pt-sm">
<q-badge
v-if="extensionData.settings_data.type === 'user'"
outline
class="text-caption q-ml-md"
>Each user can set its own settings for this extension.</q-badge
>
<q-badge v-else outline class="text-caption q-ml-md"
>Settings are set by the admin and apply to all users of the
extension</q-badge
>
</div>
</div>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.settings_data.fields"
:hide-advanced="true"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="3"
:done="step > 3"
title="Owner Data"
icon="list"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep3"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('owner_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-input
v-model="extensionData.owner_data.name"
filled
label="Owner Table Name"
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
The owner of the extension manages this data. It can add, remove
and update instances of it.
</li>
<li>
Some fileds are present by default, like
<code>created_at</code>, <code>updated_at</code> and
<code>extra</code>.
</li>
</ul>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.owner_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="4"
:done="step > 4"
title="Client Data"
icon="blur_linear"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep4"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('client_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<!-- <q-toggle
v-model="extensionData.client_data.enabled"
label="Generate Client Table"
disable
size="md"
color="green"
/>
<br /> -->
<q-input
v-if="extensionData.client_data.enabled"
v-model="extensionData.client_data.name"
filled
label="Client Table Name"
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
This data is created by users of the extension. Usually when
they submit a form or make a payment.
</li>
<li>
The owner of the extension can view this data, but should not
modify it.
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.client_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.client_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="5"
:done="step > 5"
title="Public Pages"
icon="link"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep5"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('public_page')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.public_page.has_public_page"
label="Generate Public Page"
size="md"
color="green"
/>
<br />
<ul>
<li>
Most extensions have a public page that can be shared (this page
will still be accessible even if you have restricted access to
your LNbits install).
</li>
</ul>
</div>
</div>
<div v-if="extensionData.public_page.has_public_page">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page title</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a title for the public
page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.name"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</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>Public page description</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a description for the
public page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.description"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</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>Public page inputs</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the fields from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be shown as inputs in
the public page form.
</li>
<li>You can select multiple fields.</li>
<li>
A corresponding input field will be created for each
selected field.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
multiple
use-chips
v-model="extensionData.public_page.client_data_fields.public_inputs"
:options="extensionData.client_data.fields.map(f => f.name)"
></q-select>
</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>Generate Action Button</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the public page will have a button to
perform an action (e.g. generate a payment request).
</li>
<li>
The action will use the selected input fields from
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) as parameters.
</li>
<li>
A corresponding REST API endpoint will be created.
</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_action"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
</div>
<q-separator
v-if="extensionData.public_page.action_fields.generate_action"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.public_page.action_fields.generate_action">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Payment Logic</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the endpoint will create an invoice from
the submitted data and the UI will show the QR code
with the invoice.
</li>
<li>
A listener will be created to check for the pay event.
</li>
<li>You must map the fieds.</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_payment_logic"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6"></div>
</div>
<div
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
class="row q-col-gutter-md q-mt-md"
>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Wallet</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the wallet which
will generate the invoice and receive the payments.
</li>
<li>
Only fields with the type <code>Wallet</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.wallet_id"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
></q-select>
</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>Currency</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the currency
which will be used to for the amount.
</li>
<li>
Only fields with the type <code>Currency</code> will
be shown.
</li>
<li>Empty if you want to use sats.</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.currency"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
></q-select>
</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>Amount</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) or
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that represents the amount (in
the selected currency).
</li>
<li>
Only fields with the type <code>Integer</code> and
<code>Float</code> will be shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<div class="row">
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount_source"
:options="amountSource"
class="q-mr-sm"
></q-select>
</div>
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount"
:options="paymentActionAmountFields"
></q-select>
</div>
</div>
</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>Paid Flag</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be set to true when
the invoice is paid.
</li>
<li>
Only fields with the type <code>Boolean</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.paid_flag"
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
</div>
</div>
</div>
</q-step>
<q-step
:name="6"
:done="step > 6"
title="Publish"
icon="publish"
style="min-height: 100px"
>
<div v-if="g.user.admin" class="row">
<div class="col-md-4 col-sm-12 col-xs-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.stub_version"
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
:options="extensionStubVersions.map(f => f.version)"
></q-select>
</div>
<div class="col-md-4 col-sm-12 col-xs-12">
<q-btn
@click="cleanCacheData()"
color="grey"
outline
label="Clean Cache"
class="q-ml-md"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
The extension builder uses caching to speed up the build
process.
</li>
<li>
This action clears old data and redownloads the Extension
Builder Stub release.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
<div v-if="g.user.admin" class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtensionAndDeploy()"
color="primary"
label="Build and Deploy (Admin Only)"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Installs the extension directly to this LNbits instance.
</li>
<li>
The extension will be enabled by default, and available to
all users.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
</div>
<div class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtension()"
outline
color="gray"
label="Download Extension Zip"
icon="download"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
Builds the extension and downloads a zip file with the code.
You can then install it manually in your LNbits instance.
</q-tooltip>
</q-icon>
</div>
</div>
</div>
</q-step>
<template v-slot:navigation>
<q-separator></q-separator>
<div class="row">
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
<q-btn
v-if="step == 1"
label="Clear All Data"
color="negative"
@click="clearAllData"
></q-btn>
<q-btn
v-else
flat
color="grey-8"
class="q-mr-sm"
@click="previousStep()"
label="Back"
icon="chevron_left"
/>
</div>
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
<q-stepper-navigation class="float-right">
<q-btn
v-if="step < 6"
@click="nextStep()"
color="primary"
label="Next"
></q-btn>
<template v-else>
<q-btn
@click="exportJsonData()"
color="primary"
label="Export JSON Data"
></q-btn>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Exports the config JSON so it can be later imported or
shared.
</li>
<li>
This JSON is also added to the zip in a file called
`builder.json`.
</li>
</ul>
</q-tooltip>
</q-icon>
</template>
</q-stepper-navigation>
</div>
</div>
</template>
</q-stepper>
</div>
</div>
<div class="row q-col-gutter-md"></div>
{% endblock %}
@@ -1,150 +0,0 @@
{% 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>
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)
}
}
}
})
</script>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}
-710
View File
@@ -1,710 +0,0 @@
{% extends "public.html" %} {% block scripts %}
<style>
:root {
--size: 100px;
--gap: 25px;
}
.btn-fixed-width {
/* width: 45%; */
}
.wrapper {
display: flex;
flex-direction: column;
gap: var(--gap);
margin: auto;
max-width: 100%;
}
.marquee {
display: flex;
overflow: hidden;
user-select: none;
gap: var(--gap);
height: max-content;
mask-image: linear-gradient(
to right,
hsl(0 0% 0% / 0),
hsl(0 0% 0% / 1) 20%,
hsl(0 0% 0% / 1) 80%,
hsl(0 0% 0% / 0)
);
}
.marquee__group {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-around;
gap: var(--gap);
min-width: 100%;
animation: scroll-x 60s linear infinite;
}
.marquee:hover .marquee__group {
animation-play-state: paused;
}
.marquee__group div {
width: var(--size);
}
@keyframes scroll-x {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
</style>
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
{% endblock %} {% block page_container %}
<q-page-container>
<q-page
class="q-px-md q-py-lg content-center"
:class="{'q-px-lg': $q.screen.gt.xs}"
>
{% block page %}
<div
class="row justify-center items-center"
style="min-height: calc(100vh / 1.618)"
>
<div
class="full-width"
:style="`max-width: ${'{{ LNBITS_CUSTOM_IMAGE }}' ? '850' : '600'}px`"
>
<div class="row q-mb-md">
<div class="col-12">
<div>
<h5
class="q-my-none"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TITLE}}
</h5>
<template v-if="$q.screen.gt.sm">
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
</template>
<!-- <div
class="gt-sm"
v-html="formatDescription"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
></div> -->
</div>
</div>
</div>
<div class="row">
<q-badge
v-if="isAccessTokenExpired"
class="q-mx-auto q-mb-md"
color="primary"
rounded
>
<div class="text-h6">
<span v-text="$t('session_has_expired')"></span>
</div>
</q-badge>
<q-card bordered class="full-width q-py-md">
<div class="row">
<div
class="col-12"
:class="{'col-sm-7' : '{{ LNBITS_CUSTOM_IMAGE }}', 'col-lg-6' : '{{ LNBITS_CUSTOM_IMAGE }}'}"
>
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only"
in LNBITS_AUTH_METHODS)%}
<div class="full-height content-center">
<q-card-section>
<div class="text-body1">
<span v-text="$t('claim_desc')"></span>
</div>
</q-card-section>
<q-card-section>
<q-btn
unelevated
color="primary"
@click="processing"
type="a"
href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')"
class="full-width"
></q-btn>
</q-card-section>
</div>
{%else%}
<username-password
v-if="authMethod != 'user-id-only'"
:allowed_new_users="allowedRegister"
:auth-methods="LNBITS_AUTH_METHODS"
:auth-action="authAction"
v-model:user-name="username"
v-model:password_1="password"
v-model:password_2="passwordRepeat"
v-model:reset-key="reset_key"
@login="login"
@register="register"
@reset="reset"
>
<div
class="text-center text-grey-6"
v-if="authAction !== 'reset'"
>
<p
v-if="authAction === 'login' && allowedRegister"
class="q-mb-none"
>
Not registered?
<a
href="#"
class="text-secondary cursor-pointer"
@click.prevent="showRegister('username-password')"
>Create an Account</a
>
</p>
<p
v-else-if="authAction === 'login' && !allowedRegister"
class="q-mb-none"
>
<span v-text="$t('new_user_not_allowed')"></span>
</p>
<p v-else-if="authAction === 'register'" class="q-mb-none">
<span v-text="$t('existing_account_question')"></span>
<a
href="#"
class="text-secondary cursor-pointer q-ml-sm"
@click.prevent="showLogin('username-password')"
v-text="$t('login')"
></a>
</p>
</div>
</username-password>
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
<user-id-only
:allowed_new_users="allowedRegister"
v-model:usr="usr"
v-model:wallet="walletName"
:auth-action="authAction"
:auth-method="authMethod"
@show-login="showLogin"
@show-register="showRegister"
@login-usr="loginUsr"
@create-wallet="createWallet"
>
</user-id-only>
{%endif%} {% endif %}
</div>
<div
class="col-sm-5 col-lg-6 gt-xs"
v-if="'{{ LNBITS_CUSTOM_IMAGE }}'"
>
<div class="full-height flex flex-center q-pa-lg">
<q-img
:src="'{{ LNBITS_CUSTOM_IMAGE }}'"
:ratio="1"
width="250px"
></q-img>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width q-mb-lg q-mt-sm"
>
<div class="flex flex-center q-gutter-md q-py-md">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener noreferrer"
:label="$t('view_github')"
class=""
></q-btn>
<q-btn
outline
color="grey"
type="a"
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
target="_blank"
rel="noopener noreferrer"
:label="$t('donate')"
class=""
></q-btn>
</div>
</div>
{% if AD_SPACE_ENABLED and AD_SPACE %}
<div class="q-pt-md full-width">
<div class="row justify-center q-mb-xl">
<div class="full-width text-center">
<span class="text-uppercase text-grey">{{ AD_SPACE_TITLE }}</span>
</div>
<div class="flex flex-center columm">
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="flex flex-center column q-pr-sm">
<a href="{{ AD[0] }}">
<img
v-if="($q.dark.isActive)"
src="{{ AD[1] }}"
style="max-width: 420px"
class="full-width"
/>
<img
v-else
src="{{ AD[2] }}"
style="max-width: 420px"
class="full-width"
/>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width"
>
<div class="wrapper">
<div class="marquee">
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}
File diff suppressed because it is too large Load Diff
-165
View File
@@ -1,165 +0,0 @@
{% 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 %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block scripts %} {{ window_vars() }} {% endblock %} {% block page
%} {% endblock %}
@@ -1,379 +0,0 @@
<q-tab-panel name="channels">
<q-dialog v-model="connectPeerDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="connectPeerDialog.data.uri"
label="Node URI"
hint="pubkey@host:port"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('connect')"
color="primary"
@click="connectPeer"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="setFeeDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<label class="text-h6">Set Channel Fee</label>
<p class="text-caption" v-text="setFeeDialog.channel_id"></p>
<q-separator></q-separator>
<q-form class="q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_ppm"
label="Fee Rate PPM"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_base_msat"
label="Fee Base msat"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('set')"
color="primary"
@click="setChannelFee(setFeeDialog.channel_id)"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="openChannelDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-input
dense
type="text"
filled
v-model="openChannelDialog.data.peer_id"
label="Peer ID"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.funding_amount"
label="Funding Amount"
></q-input>
<q-expansion-item icon="warning" label="Advanced">
<q-card>
<q-card-section>
<div class="column q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.push_amount"
label="Push Amount"
hint="This gifts sats to the other side!"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="openChannelDialog.data.fee_rate"
label="Fee Rate"
></q-input>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
:label="$t('open')"
color="primary"
@click="openChannel"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="closeChannelDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<div>
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
</div>
<div class="row q-mt-lg">
<q-btn
:label="$t('close')"
color="primary"
@click="closeChannel"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-card-section class="q-pa-none">
<div class="row q-col-gutter-lg">
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="q-gutter-y-sm">
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
<q-input
filled
dense
clearable
v-model="channels.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-select
dense
size="sm"
style="min-width: 200px"
filled
multiple
clearable
v-model="stateFilters"
:options="this.states"
class="col-auto"
></q-select>
<q-btn
unelevated
color="primary"
size="md"
class="col-auto"
@click="showOpenChannelDialog()"
>
Open channel
</q-btn>
</div>
<div>
<div class="text-subtitle1 col-grow">Total</div>
<lnbits-channel-balance
:balance="this.totalBalance"
></lnbits-channel-balance>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:rows="this.filteredChannels"
:filter="channels.filter"
no-data-label="No channels opened"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<div class="q-pb-sm">
<div class="row items-center q-gutter-sm">
<div class="text-subtitle1" v-text="props.row.name"></div>
<div class="text-caption" v-if="props.row.peer_id">
<span>Peer ID</span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.peer_id)"
></q-btn>
</div>
<div class="text-caption col-grow">
<span>Fees</span>
<q-btn
size="xs"
flat
dense
icon="settings"
@click="showSetFeeDialog(props.row.id)"
></q-btn>
<span v-if="props.row.fee_ppm">
<span v-text="props.row.fee_ppm"></span> ppm,
<span v-text="props.row.fee_base_msat"></span> msat
</span>
</div>
<div class="text-caption" v-if="props.row.id">
<span>Channel ID</span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
</div>
<div class="text-caption" v-if="props.row.short_id">
<span v-text="props.row.short_id"></span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.short_id)"
></q-btn>
</div>
<q-badge
rounded
:color="states.find(s => s.value == props.row.state)?.color"
v-text="states.find(s => s.value == props.row.state)?.label"
>
</q-badge>
<q-btn
:disable='props.row.state !== "active"'
flat
dense
size="md"
@click="showCloseChannelDialog(props.row)"
icon="cancel"
color="pink"
></q-btn>
</div>
<lnbits-channel-balance
:balance="props.row.balance"
:color="props.row.color"
></lnbits-channel-balance>
</div>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-xl-6">
<q-card class="full-height">
<q-card-section class="column q-gutter-y-sm">
<div
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
>
<div class="col-grow text-h6 q-my-none">Peers</div>
<q-input
filled
dense
clearable
v-model="peers.filter"
placeholder="Search..."
class="col-auto"
></q-input>
<q-btn
class="col-auto"
color="primary"
@click="connectPeerDialog.show = true"
>
Connect Peer
</q-btn>
</div>
<q-separator></q-separator>
<q-table
dense
flat
:rows="peers.data"
:filter="peers.filter"
no-data-label="No transactions made yet"
>
<template v-slot:header="props">
<q-tr :props="props" style="height: 0"> </q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<div class="row no-wrap items-center q-gutter-sm">
<div class="q-my-sm col-grow">
<div
class="text-subtitle1 text-bold"
v-text="props.row.alias"
></div>
<div class="row items-center q-gutter-sm">
<q-badge
:style="`background-color: #${props.row.color}`"
class="text-bold"
v-text="'#'+props.row.color"
>
</q-badge>
<div
class="text-bold"
v-text="shortenNodeId(props.row.id)"
></div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row)"
></q-btn>
</div>
</div>
<q-btn
unelevated
color="primary"
@click="showOpenChannelDialog(props.row.id)"
>
Open channel
</q-btn>
<q-btn
flat
dense
size="md"
@click="disconnectPeer(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</div>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -1,68 +0,0 @@
<q-tab-panel name="dashboard">
<q-card-section class="q-pa-none">
<lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('total_capacity')"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Fees collected"
:msat="this.info.fees?.total_msat"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Balance"
:btc="this.info.onchain_balance_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
title="Onchain Confirmed"
:btc="this.info.onchain_confirmed_sat / 100000000"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats
:stats="this.channel_stats"
></lnbits-channel-stats>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -1,314 +0,0 @@
<q-tab-panel name="transactions">
<q-card-section class="q-pa-none">
<q-dialog v-model="transactionDetailsDialog.show" position="top">
<q-card class="my-card">
<q-card-section>
<div class="text-center q-mb-lg">
<div
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
>
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
</div>
<div class="row q-my-md">
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
<div class="col-9 text-wrap mono">
<span
v-text="transactionDetailsDialog.data.payment_hash"
></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.payment_hash)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
<div
class="row"
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
>
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
<div class="col-9 text-wrap mono">
<span v-text="transactionDetailsDialog.data.preimage"></span>
<q-icon
name="content_copy"
@click="copyText(transactionDetailsDialog.data.preimage)"
size="1em"
color="grey"
class="q-mb-xs cursor-pointer"
/>
</div>
</div>
</div>
<div
v-if="transactionDetailsDialog.data.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode-vue
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode-vue>
</q-responsive>
</a>
<q-btn
outline
color="grey"
@click="copyText(transactionDetailsDialog.data.bolt11)"
:label="$t('copy_invoice')"
class="q-mt-sm"
></q-btn>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<div class="row q-col-gutter-md q-pb-lg"></div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Payments</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:rows="paymentsTable.data"
:columns="paymentsTable.columns"
v-model:pagination="paymentsTable.pagination"
row-key="payment_hash"
no-data-label="No transactions made yet"
:filter="paymentsTable.filter"
@request="getPayments"
>
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_made"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
<q-dialog
v-model="props.row.expand"
:props="props"
position="top"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<q-icon
name="settings_ethernet"
color="grey"
></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div
v-if="props.row.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode-vue
:value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode-vue>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon
name="settings_ethernet"
color="grey"
></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</q-td>
</template>
<template v-slot:body-cell-date="props">
<q-td auto-width key="date" :props="props">
<lnbits-date :ts="props.row.time"></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-destination="props">
<q-td auto-width key="destination">
<div class="row items-center justify-between no-wrap">
<q-badge
:style="`background-color: #${props.row.destination?.color}`"
class="text-bold"
v-text="props.row.destination?.alias"
></q-badge>
<div>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(info.id)"
></q-btn>
<q-btn
size="xs"
flat
dense
icon="qr_code"
@click="showNodeInfoDialog(props.row.destination)"
></q-btn>
</div>
</div>
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col text-h6 q-my-none">Invoices</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
</div>
<q-table
dense
flat
:rows="invoiceTable.data"
:columns="invoiceTable.columns"
v-model:pagination="invoiceTable.pagination"
no-data-label="No transactions made yet"
:filter="invoiceTable.filter"
@request="getInvoices"
>
<template v-slot:body-cell-pending="props">
<q-td auto-width class="text-center">
<q-icon
v-if="!props.row.pending"
size="xs"
name="call_received"
color="green"
@click="showTransactionDetailsDialog(props.row)"
></q-icon>
<q-icon
v-else
size="xs"
name="settings_ethernet"
color="grey"
@click="showTransactionDetailsDialog(props.row)"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
</template>
<template v-slot:body-cell-paid_at="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.paid_at"
:ts="props.row.paid_at"
></lnbits-date>
</q-td>
</template>
<template v-slot:body-cell-expiry="props">
<q-td auto-width :props="props">
<lnbits-date
v-if="props.row.expiry"
:ts="props.row.expiry"
></lnbits-date>
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-tab-panel>
-46
View File
@@ -1,46 +0,0 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<q-dialog v-model="nodeInfoDialog.show" position="top">
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
</q-dialog>
<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-tab
name="dashboard"
:label="$t('dashboard')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="channels"
:label="$t('channels')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="transactions"
:label="$t('transactions')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div>
</div>
<q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated>
{% include "node/_tab_dashboard.html" %} {% include
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
%}
</q-tab-panels>
</q-form>
</q-card>
</div>
</div>
{% endblock %}
-133
View File
@@ -1,133 +0,0 @@
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
context %} {% block page %}
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
<lnbits-node-info :info="this.info"></lnbits-node-info>
<div class="row q-col-gutter-lg q-mt-sm">
<div class="col-12 col-md-8 q-gutter-y-md">
<div class="row q-col-gutter-md q-pb-lg">
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('total_capacity')"
:msat="this.channel_stats.total_capacity"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('avg_channel_size')"
:msat="this.channel_stats.avg_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('biggest_channel_size')"
:msat="this.channel_stats.biggest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
<div class="col-12 col-md-6 q-gutter-y-md">
<lnbits-stat
:title="$t('smallest_channel_size')"
:msat="this.channel_stats.smallest_size"
/>
</div>
</div>
</div>
<div class="column col-12 col-md-4 q-gutter-y-md">
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
</div>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
<script>
window.app = Vue.createApp({
el: '#vue',
config: {
globalProperties: {
LNbits,
msg: 'hello'
}
},
mixins: [window.windowMixin],
data: function () {
return {
isSuperUser: false,
wallet: {},
tab: 'dashboard',
payments: 1000,
info: {},
channel_stats: {},
channels: [],
activeBalance: {},
ranks: {},
peers: [],
connectPeerDialog: {
show: false,
data: {}
},
openChannelDialog: {
show: false,
data: {}
},
closeChannelDialog: {
show: false,
data: {}
},
nodeInfoDialog: {
show: false,
data: {}
},
states: [
{label: 'Active', value: 'active', color: 'green'},
{label: 'Pending', value: 'pending', color: 'orange'},
{label: 'Inactive', value: 'inactive', color: 'grey'},
{label: 'Closed', value: 'closed', color: 'red'}
]
}
},
created: function () {
this.getInfo()
this.get1MLStats()
},
methods: {
formatMsat: function (msat) {
return LNbits.utils.formatMsat(msat)
},
api: function (method, url, data) {
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
},
getInfo: function () {
this.api('GET', '/info', {}).then(response => {
this.info = response.data
this.channel_stats = response.data.channel_stats
})
},
get1MLStats: function () {
this.api('GET', '/rank', {}).then(response => {
this.ranks = response.data
})
}
}
})
</script>
{% endblock %}
-432
View File
@@ -1,432 +0,0 @@
{% 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"></div>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentStatus"
:label="$t('payments_status_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentTags"
:label="$t('payments_tag_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalance"
:label="$t('payments_balance_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showWalletsSize"
:label="$t('payments_wallets_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalanceInOut"
:label="$t('payments_balance_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentCountInOut"
:label="$t('payments_count_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<q-btn icon="event" outline flat>
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
<div class="row">
<div class="col-6">
<q-btn
label="Search"
@click="searchByDate()"
color="primary"
flat
class="float-left"
v-close-popup
/>
</div>
<div class="col-6">
<q-btn
v-close-popup
@click="clearDateSeach()"
label="Clear"
class="float-right"
color="grey"
flat
/>
</div>
</div>
</q-popup-proxy>
<q-badge
v-if="searchDate?.to || searchDate?.from"
class="q-mt-lg q-mr-md"
color="primary"
rounded
floating
style="border-radius: 6px"
/>
</q-btn>
<q-separator vertical class="q-ma-sm"></q-separator>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#server"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div v-show="!showDetails">
<div class="row q-col-gutter-md justify-center q-mb-md">
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_status')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsStatusChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tags')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsTagsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalance"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong
v-text="$t('lnbits_balance', {balance: (lnbitsBalance || 0).toLocaleString()})"
></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsDailyChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showWalletsSize"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tx_per_wallet')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsWalletsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalanceInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_balance_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsBalanceInOutChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentCountInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_count_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsCountInOutChart"></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="payment_hash"
:rows="payments"
:columns="paymentsTable.columns"
v-model:pagination="paymentsTable.pagination"
:filter="paymentsTable.search"
@request="fetchPayments"
>
<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="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchPaymentsBy()"
@update:model-value="searchPaymentsBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchPaymentsBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-btn
v-else-if="['status'].includes(col.name)"
flat
dense
:label="$q.screen.gt.md ? 'Status' : null"
icon="filter_alt"
color="grey"
class="text-capitalize"
>
<q-menu anchor="top right" self="top start">
<q-item dense>
<q-checkbox
v-model="statusFilters.success"
@click="handleFilterChanged"
label="Success Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.pending"
@click="handleFilterChanged"
label="Pending Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.failed"
@click="handleFilterChanged"
label="Failed Payments"
></q-checkbox>
</q-item>
<q-separator></q-separator>
<q-item dense>
<q-checkbox
v-model="statusFilters.incoming"
@click="handleFilterChanged"
label="Incoming Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.outgoing"
@click="handleFilterChanged"
label="Outgoing Payments"
></q-checkbox>
</q-item>
</q-menu>
<q-tooltip>
<span v-text="$t('filter_payments')"></span>
</q-tooltip>
</q-btn>
<q-select
v-else-if="['tag'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchPaymentsBy()"
: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 == 'status'">
<q-tooltip
><span v-text="$t('payment_details')"></span
></q-tooltip>
<q-icon
@click="showDetailsToggle(props.row)"
v-if="props.row.status === 'success'"
size="14px"
:name="props.row.outgoing ? 'call_made' : 'call_received'"
:color="props.row.outgoing ? 'pink' : 'green'"
class="cursor-pointer"
></q-icon>
<q-icon
v-else-if="props.row.status === 'pending'"
@click="showDetailsToggle(props.row)"
name="downloading"
:style="props.row.outgoing ? 'transform: rotate(225deg)' : 'transform: scaleX(-1) rotate(315deg)'"
color="grey"
class="cursor-pointer"
></q-icon>
<q-icon
v-else
@click="showDetailsToggle(props.row)"
name="warning"
color="yellow"
class="cursor-pointer"
></q-icon>
</div>
<div v-else-if="col.name == 'created_at'">
<div>
<q-tooltip anchor="top middle">
<span v-text="formatDate(props.row.created_at)"></span>
</q-tooltip>
<span v-text="props.row.timeFrom"> </span>
</div>
</div>
<div
v-else-if="['wallet_id', 'payment_hash', 'memo'].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 anchor="top middle">Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name], col.max_length)">
</span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
</div>
<div v-show="showDetails">
<q-card>
<q-card-section class="flex">
<div>
<q-btn
flat
round
icon="arrow_back"
class="q-mr-md"
@click="showDetailsToggle(null)"
></q-btn>
</div>
<div class="self-center text-h6 text-weight-bolder text-grey-5">
<span v-text="$t('payment_details_back')"></span>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="text-h6">
<q-item>
<q-item-section avatar class="">
<q-icon color="primary" name="receipt" size="44px"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>
<div class="text-h6">
<span v-text="$t('payment_details')"></span>
</div>
</q-item-label>
<q-item-label caption v-text="$t('payment_details_desc')">
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
<q-card-section>
<q-list separator>
<q-item v-for="(value, key) in paymentDetails" :key="key">
<q-item-section>
<q-item-label v-text="key"></q-item-label>
<q-item-label
caption
v-text="value"
style="word-wrap: break-word"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
v-show="value"
icon="content_copy"
flat
class="cursor-pointer q-ml-sm"
@click="copyText(value)"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
</q-item-section>
<!-- <q-separator></q-separator> -->
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
{% endblock %}
@@ -1,44 +0,0 @@
<q-dialog v-model="createWalletDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>Create Wallet</strong>
<div class="row">
<div class="col-12">
<div class="row q-mt-lg">
<div class="col">
<q-input
v-model="createWalletDialog.data.name"
:label='$t("name_your_wallet")'
filled
dense
class="q-mb-md"
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col">
<q-select
filled
dense
v-model="createWalletDialog.data.currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-close-popup
@click="createWallet()"
unelevated
color="primary"
type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</div>
</q-card>
</q-dialog>
@@ -1,196 +0,0 @@
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
v-if="activeUser.data.id"
@click="updateUser()"
color="primary"
:label="$t('update_account')"
class="q-ml-md"
></q-btn>
<q-btn
v-else
@click="createUser()"
:label="$t('create_account')"
color="primary"
class="float-right"
></q-btn>
</div>
</div>
<q-card v-if="activeUser.show" class="q-pa-md">
<q-card-section>
<div class="text-h6">
<span v-if="activeUser.data.id" v-text="$t('update_account')"></span>
<span v-else v-text="$t('create_account')"></span>
</div>
</q-card-section>
<q-card-section>
<q-input
v-if="activeUser.data.id"
v-model="activeUser.data.id"
:label="$t('user_id')"
filled
dense
readonly
:type="activeUser.data.showUserId ? 'text': 'password'"
class="q-mb-md"
><q-btn
@click="activeUser.data.showUserId = !activeUser.data.showUserId"
dense
flat
:icon="activeUser.data.showUserId ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.username"
:label="$t('username')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-toggle
size="xs"
v-if="!activeUser.data.id"
color="secondary"
:label="$t('set_password')"
v-model="activeUser.setPassword"
>
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
</q-toggle>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password"
:type="activeUser.data.showPassword ? 'text': 'password'"
autocomplete="off"
:label="$t('password')"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password_repeat"
:type="activeUser.data.showPassword ? 'text': 'password'"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
filled
dense
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.pubkey"
:label="'Nostr '+ $t('pubkey')"
filled
dense
class="q-mb-md"
>
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
</q-input>
<q-input
v-model="activeUser.data.email"
:label="$t('email')"
filled
dense
class="q-mb-md"
>
</q-input>
</q-card-section>
<q-card-section v-if="activeUser.data.extra">
<q-input
v-model="activeUser.data.extra.first_name"
:label="$t('first_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.last_name"
:label="$t('last_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.provider"
:label="$t('auth_provider')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.external_id"
:label="$t('external_id')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-select
filled
dense
v-model="activeUser.data.extensions"
multiple
label="User extensions"
:options="g.extensions"
></q-select>
</q-card-section>
<q-card-section v-if="activeUser.data.id">
<q-btn
@click="resetPassword(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('reset_password')"
icon="refresh"
color="primary"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn
@click="deleteUser(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('delete')"
icon="delete"
color="negative"
class="float-right"
>
<q-tooltip>Delete User</q-tooltip></q-btn
>
</q-card-section>
</q-card>
@@ -1,190 +0,0 @@
<div v-if="paymentPage.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="paymentPage.show = false"
:label="$t('back')"
></q-btn>
</div>
</div>
<q-card class="q-pa-md">
<q-card-section>
<payment-list :wallet="paymentsWallet" />
</q-card-section>
</q-card>
</div>
<div v-else-if="activeWallet.show">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row">
<div class="q-pa-xs">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="deleteAllUserWallets(activeWallet.userId)"
:label="$t('delete_all_wallets')"
icon="delete"
color="negative"
></q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
<h2 class="text-h6 q-mb-md">Wallets</h2>
<q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width v-if="g.user.super_user"></q-th>
<q-th auto-width></q-th>
<q-th
auto-width
v-for="col in props.cols"
v-text="col.label"
:key="col.name"
:props="props"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width v-if="g.user.super_user">
<lnbits-update-balance
:wallet_id="props.row.id"
@credit-value="handleBalanceUpdate"
class="q-mr-md"
></lnbits-update-balance>
</q-td>
<q-td auto-width>
<q-btn
round
icon="menu"
size="sm"
color="secondary"
@click="showPayments(props.row.id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.adminkey)"
>
<q-tooltip>Copy Admin Key</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="secondary"
class="q-ml-xs"
@click="copyText(props.row.inkey)"
>
<q-tooltip>Copy Invoice Key</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Delete Wallet</q-tooltip>
</q-btn>
<q-btn
round
v-if="props.row.deleted"
icon="toggle_off"
size="sm"
color="secondary"
class="q-ml-xs"
@click="undeleteUserWallet(props.row.user, props.row.id)"
>
<q-tooltip>Undelete Wallet</q-tooltip>
</q-btn>
<q-btn
icon="link"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyWalletLink(props.row.id)"
>
<q-tooltip>Copy Wallet Link</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<span
v-text="props.row.name"
v-if="!props.row.editable"
:class="props.row.deleted ? 'text-strike' : 'cursor-pointer'"
@click="props.row.editable = true && !props.row.deleted"
></span>
<q-input
v-else
@keydown.enter="updateWallet(props.row)"
v-model="props.row.name"
size="xs"
flat
dense
>
<template v-slot:append>
<q-btn
@click="updateWallet(props.row)"
round
dense
size="xs"
flat
icon="send"
/>
</template>
</q-input>
</q-td>
<q-td auto-width>
<q-btn
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
</q-btn>
<span
v-text="props.row.id"
:class="props.row.deleted ? 'text-strike' : ''"
></span>
</q-td>
<q-td auto-width v-text="props.row.currency"></q-td>
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
-186
View File
@@ -1,186 +0,0 @@
{% 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 justify-center">
<div class="col">
{% include "users/_manageWallet.html" %}
<div v-if="activeUser.show" class="row">
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
</div>
<div v-else-if="activeWallet.show">
{%include "users/_createWalletDialog.html" %}
</div>
<div v-else>
<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
@click="showAccountPage()"
:label="$t('create_account')"
color="primary"
>
</q-btn>
</div>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#users"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="users"
:columns="usersTable.columns"
v-model:pagination="usersTable.pagination"
:no-data-label="$t('no_users')"
:filter="usersTable.search"
:loading="usersTable.loading"
@request="fetchUsers"
>
<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">
<q-input
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchUserBy(col.name)"
dense
type="text"
filled
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchUserBy(col.name)"
class="cursor-pointer"
/>
</template>
</q-input>
<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>
<q-btn
@click="showAccountPage(props.row.id)"
round
icon="edit"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>
<span v-text="$t('update_account')"></span>
</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-toggle
size="xs"
v-if="!props.row.is_super_user"
color="secondary"
v-model="props.row.is_admin"
@update:model-value="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-btn
round
v-if="props.row.is_super_user"
icon="verified"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="list"
size="sm"
color="secondary"
:label="props.row.wallet_count"
@click="fetchWallets(props.row.id)"
>
</q-btn>
<q-btn
v-if="(users.length == 1) && searchData.wallet_id"
round
icon="menu"
size="sm"
color="secondary"
class="q-ml-sm"
@click="showWalletPayments(searchData.wallet_id)"
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy User ID</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.id)"></span>
</q-td>
<q-td v-text="props.row.username"></q-td>
<q-td v-text="props.row.email"></q-td>
<q-td>
<q-btn
v-if="props.row.pubkey"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.pubkey)"
>
<q-tooltip>Copy Public Key</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.pubkey)"></span>
</q-td>
<q-td v-text="formatSat(props.row.balance_msat)"></q-td>
<q-td v-text="props.row.transaction_count"></q-td>
<q-td v-text="formatDate(props.row.last_payment)"></q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
</div>
{% endblock %}
+16 -108
View File
@@ -1,20 +1,15 @@
import os
import time
from http import HTTPStatus
from pathlib import Path
from shutil import make_archive, move
from shutil import make_archive
from subprocess import Popen
from tempfile import NamedTemporaryFile
from typing import IO
from urllib.parse import urlparse
import filetype
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File
from fastapi.responses import FileResponse
from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import Account
from lnbits.core.services import (
enqueue_admin_notification,
get_balance_delta,
@@ -23,7 +18,6 @@ from lnbits.core.services import (
from lnbits.core.services.notifications import send_email_notification
from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.helpers import safe_upload_file_path
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
@@ -73,9 +67,9 @@ async def api_test_email():
@admin_router.get("/api/v1/settings")
async def api_get_settings(
user: User = Depends(check_admin),
account: Account = Depends(check_admin),
) -> AdminSettings | None:
admin_settings = await get_admin_settings(user.super_user)
admin_settings = await get_admin_settings(account.is_super_user)
return admin_settings
@@ -83,12 +77,14 @@ async def api_get_settings(
"/api/v1/settings",
status_code=HTTPStatus.OK,
)
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
async def api_update_settings(
data: UpdateSettings, account: Account = Depends(check_admin)
):
enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
NotificationType.settings_update, {"username": account.username}
)
await update_admin_settings(data)
admin_settings = await get_admin_settings(user.super_user)
admin_settings = await get_admin_settings(account.is_super_user)
if not admin_settings:
raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.dict())
@@ -100,9 +96,11 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
"/api/v1/settings",
status_code=HTTPStatus.OK,
)
async def api_update_settings_partial(data: dict, user: User = Depends(check_admin)):
async def api_update_settings_partial(
data: dict, account: Account = Depends(check_admin)
):
updatable_settings = dict_to_settings({**settings.dict(), **data})
return await api_update_settings(updatable_settings, user)
return await api_update_settings(updatable_settings, account)
@admin_router.get(
@@ -116,9 +114,9 @@ async def api_reset_settings(field_name: str):
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
async def api_delete_settings(account: Account = Depends(check_super_user)) -> None:
enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
NotificationType.settings_update, {"username": account.username}
)
await reset_core_settings()
server_restart.set()
@@ -172,93 +170,3 @@ async def api_download_backup() -> FileResponse:
return FileResponse(
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
)
@admin_router.post(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def upload_image(
file: UploadFile = file_upload,
content_length: int = Header(..., le=settings.lnbits_upload_size_bytes),
) -> Image:
if not file.filename:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="No filename provided."
)
# validate file types
file_info = filetype.guess(file.file)
if file_info is None:
raise HTTPException(
status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
detail="Unable to determine file type",
)
detected_content_type = file_info.extension.lower()
if (
file.content_type not in settings.lnbits_upload_allowed_types
or detected_content_type not in settings.lnbits_upload_allowed_types
):
raise HTTPException(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported file type")
# validate file name
try:
file_path = safe_upload_file_path(file.filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{file.filename}' is forbidden.",
) from e
# validate file size
real_file_size = 0
temp: IO = NamedTemporaryFile(delete=False)
for chunk in file.file:
real_file_size += len(chunk)
if real_file_size > content_length:
raise HTTPException(
status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large ({content_length / 1000} KB max)",
)
temp.write(chunk)
temp.close()
move(temp.name, file_path)
return Image(filename=file.filename)
@admin_router.get(
"/api/v1/images",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def list_uploaded_images() -> list[Image]:
image_folder = Path(settings.lnbits_data_folder, "images")
files = image_folder.glob("*")
images = []
for file in files:
if file.is_file():
images.append(Image(filename=file.name))
return images
@admin_router.delete(
"/api/v1/images/{filename}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def delete_uploaded_image(filename: str) -> SimpleStatus:
try:
file_path = safe_upload_file_path(filename)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"The requested filename '{filename}' is forbidden.",
) from e
if not file_path.exists():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found.")
file_path.unlink()
return SimpleStatus(success=True, message=f"{filename} deleted")
+11 -6
View File
@@ -8,13 +8,17 @@ from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from lnbits.core.models import (
BaseWallet,
ConversionData,
CreateWallet,
User,
Wallet,
)
from lnbits.decorators import check_user_exists
from lnbits.core.models.users import AccountId
from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
check_user_exists,
)
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
allowed_currencies,
@@ -39,7 +43,9 @@ async def health() -> dict:
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
async def health_check(user: User = Depends(check_user_exists)) -> dict:
async def health_check(
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
stat: dict[str, Any] = {
"server_time": int(time()),
"up_time": settings.lnbits_server_up_time,
@@ -47,7 +53,7 @@ async def health_check(user: User = Depends(check_user_exists)) -> dict:
}
stat["version"] = settings.version
if not user.admin:
if not account_id.is_admin_id:
return stat
funding_source = get_funding_source()
@@ -64,7 +70,6 @@ async def health_check(user: User = Depends(check_user_exists)) -> dict:
"/api/v1/wallets",
name="Wallets",
description="Get basic info for all of user's wallets.",
response_model=list[BaseWallet],
)
async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
return user.wallets
@@ -78,7 +83,7 @@ async def api_create_account(data: CreateWallet) -> Wallet:
@api_router.get(
"/api/v1/rate/history",
dependencies=[Depends(check_user_exists)],
dependencies=[Depends(check_account_exists)],
)
async def api_exchange_rate_history() -> list[dict]:
return settings.lnbits_exchange_rate_history
+174
View File
@@ -0,0 +1,174 @@
import base64
from http import HTTPStatus
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from lnbits.core.crud.assets import (
delete_user_asset,
get_asset_info,
get_public_asset,
get_public_asset_info,
get_user_asset,
get_user_asset_info,
get_user_assets,
update_user_asset_info,
)
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import AccountId
from lnbits.core.services.assets import create_user_asset
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_account_id_exists,
optional_user_id,
parse_filters,
)
asset_router = APIRouter(prefix="/api/v1/assets", tags=["Assets"])
upload_file_param = File(...)
@asset_router.get(
"/paginated",
name="Get user assets",
summary="Get paginated list user assets",
)
async def api_get_user_assets(
account_id: AccountId = Depends(check_account_id_exists),
filters: Filters = Depends(parse_filters(AssetFilters)),
) -> Page[AssetInfo]:
return await get_user_assets(account_id.id, filters=filters)
@asset_router.get(
"/{asset_id}",
name="Get user asset",
summary="Get user asset by ID",
)
async def api_get_asset(
asset_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> AssetInfo:
asset_info = await get_user_asset_info(account_id.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_info
@asset_router.get(
"/{asset_id}/binary",
name="Get user asset binary",
summary="Get user asset binary data by ID",
)
async def api_get_asset_binary(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset = None
if user_id:
asset = await get_user_asset(user_id, asset_id)
if not asset:
asset = await get_public_asset(asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get(
"/{asset_id}/thumbnail",
name="Get user asset thumbnail",
summary="Get user asset thumbnail data by ID",
)
async def api_get_asset_thumbnail(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
asset_info = None
if user_id:
asset_info = await get_user_asset_info(user_id, asset_id)
if not asset_info:
asset_info = await get_public_asset_info(asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=(
base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64
else b""
),
media_type=asset_info.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
)
@asset_router.put(
"/{asset_id}",
name="Update user asset",
summary="Update user asset by ID",
)
async def api_update_asset(
asset_id: str,
data: AssetUpdate,
account_id: AccountId = Depends(check_account_id_exists),
) -> AssetInfo:
if account_id.is_admin_id:
asset_info = await get_asset_info(asset_id)
else:
asset_info = await get_user_asset_info(account_id.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
asset_info.name = data.name or asset_info.name
asset_info.is_public = (
asset_info.is_public if data.is_public is None else data.is_public
)
await update_user_asset_info(asset_info)
return asset_info
@asset_router.post(
"",
name="Upload",
summary="Upload user assets",
)
async def api_upload_asset(
account_id: AccountId = Depends(check_account_id_exists),
file: UploadFile = upload_file_param,
public_asset: bool = False,
) -> AssetInfo:
asset = await create_user_asset(account_id.id, file, public_asset)
asset_info = await get_user_asset_info(account_id.id, asset.id)
if not asset_info:
raise ValueError("Failed to retrieve asset info after upload.")
return asset_info
@asset_router.delete(
"/{asset_id}",
name="Delete user asset",
summary="Delete user asset by ID",
)
async def api_delete_asset(
asset_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> SimpleStatus:
asset = await get_user_asset(account_id.id, asset_id)
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
await delete_user_asset(account_id.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.")
+28 -49
View File
@@ -25,7 +25,12 @@ from lnbits.core.models.users import (
UpdateAccessControlList,
)
from lnbits.core.services import create_user_account
from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.core.services.users import update_user_account
from lnbits.decorators import (
access_token_payload,
check_account_exists,
check_user_exists,
)
from lnbits.helpers import (
create_access_token,
decrypt_internal_message,
@@ -118,11 +123,11 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
@auth_router.get("/acl")
async def api_get_user_acls(
request: Request,
user: User = Depends(check_user_exists),
account: Account = Depends(check_account_exists),
) -> UserAcls:
api_routes = get_api_routes(request.app.router.routes)
acls = await get_user_access_control_lists(user.id)
acls = await get_user_access_control_lists(account.id)
# Add missing/new endpoints to the ACLs
for acl in acls.access_control_list:
@@ -135,7 +140,7 @@ async def api_get_user_acls(
acl.endpoints.append(EndpointAccess(path=path, name=name))
acl.endpoints.sort(key=lambda e: e.name.lower())
return UserAcls(id=user.id, access_control_list=acls.access_control_list)
return UserAcls(id=account.id, access_control_list=acls.access_control_list)
@auth_router.put("/acl")
@@ -143,13 +148,13 @@ async def api_get_user_acls(
async def api_update_user_acl(
request: Request,
data: UpdateAccessControlList,
user: User = Depends(check_user_exists),
account: Account = Depends(check_account_exists),
) -> UserAcls:
account = await get_account(user.id)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
user_acls = await get_user_access_control_lists(user.id)
user_acls = await get_user_access_control_lists(account.id)
acl = user_acls.get_acl_by_id(data.id)
if acl:
user_acls.access_control_list.remove(acl)
@@ -174,33 +179,30 @@ async def api_update_user_acl(
@auth_router.delete("/acl")
async def api_delete_user_acl(
data: DeleteAccessControlList,
user: User = Depends(check_user_exists),
data: DeleteAccessControlList, account: Account = Depends(check_account_exists)
):
account = await get_account(user.id)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
user_acls = await get_user_access_control_lists(user.id)
user_acls = await get_user_access_control_lists(account.id)
user_acls.delete_acl_by_id(data.id)
await update_user_access_control_list(user_acls)
@auth_router.post("/acl/token")
async def api_create_user_api_token(
data: ApiTokenRequest,
user: User = Depends(check_user_exists),
data: ApiTokenRequest, account: Account = Depends(check_account_exists)
) -> ApiTokenResponse:
if not data.expiration_time_minutes > 0:
raise ValueError("Expiration time must be in the future.")
account = await get_account(user.id)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
if not account.username:
raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(user.id)
acls = await get_user_access_control_lists(account.id)
acl = acls.get_acl_by_id(data.acl_id)
if not acl:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
@@ -217,18 +219,16 @@ async def api_create_user_api_token(
@auth_router.delete("/acl/token")
async def api_delete_user_api_token(
data: DeleteTokenRequest,
user: User = Depends(check_user_exists),
data: DeleteTokenRequest, account: Account = Depends(check_account_exists)
):
account = await get_account(user.id)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
if not account.username:
raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(user.id)
acls = await get_user_access_control_lists(account.id)
acl = acls.get_acl_by_id(data.acl_id)
if not acl:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
@@ -317,24 +317,20 @@ async def register(data: RegisterUser) -> JSONResponse:
@auth_router.put("/pubkey")
async def update_pubkey(
data: UpdateUserPubkey,
user: User = Depends(check_user_exists),
account: Account = Depends(check_account_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None:
if data.user_id != user.id:
if data.user_id != account.id:
raise ValueError("Invalid user ID.")
_validate_auth_timeout(payload.auth_time)
if (
data.pubkey
and data.pubkey != user.pubkey
and data.pubkey != account.pubkey
and await get_account_by_pubkey(data.pubkey)
):
raise ValueError("Public key already in use.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
account.pubkey = normalize_public_key(data.pubkey)
await update_account(account)
return await get_user_from_account(account)
@@ -343,23 +339,19 @@ async def update_pubkey(
@auth_router.put("/password")
async def update_password(
data: UpdateUserPassword,
user: User = Depends(check_user_exists),
account: Account = Depends(check_account_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None:
_validate_auth_timeout(payload.auth_time)
if data.user_id != user.id:
if data.user_id != account.id:
raise ValueError("Invalid user ID.")
if (
data.username
and user.username != data.username
and account.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
if not account:
raise ValueError("Account not found.")
# old accounts do not have a password
if account.password_hash:
if not data.password_old:
@@ -418,30 +410,17 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
@auth_router.put("/update")
async def update(
data: UpdateUser, user: User = Depends(check_user_exists)
data: UpdateUser, account: Account = Depends(check_account_exists)
) -> User | None:
if data.user_id != user.id:
if data.user_id != account.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
if data.username:
account.username = data.username
if data.extra:
account.extra = data.extra
await update_account(account)
await update_user_account(account)
return await get_user_from_account(account)
+119 -4
View File
@@ -10,6 +10,7 @@ from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import (
check_stripe_signature,
verify_paypal_webhook,
)
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
@@ -37,6 +38,17 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.",
)
if provider_name.lower() == "paypal":
payload = await request.body()
await verify_paypal_webhook(request.headers, payload)
event = await request.json()
await handle_paypal_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus(
success=False,
message=f"Unknown fiat provider '{provider_name}'.",
@@ -48,6 +60,8 @@ async def handle_stripe_event(event: dict):
event_type = event.get("type")
if event_type == "checkout.session.completed":
await _handle_stripe_checkout_session_completed(event)
elif event_type == "payment_intent.succeeded":
await _handle_stripe_intent_session_completed(event)
elif event_type == "invoice.paid":
await _handle_stripe_subscription_invoice_paid(event)
else:
@@ -56,18 +70,38 @@ async def handle_stripe_event(event: dict):
)
async def _handle_stripe_intent_session_completed(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()
async def _handle_stripe_checkout_session_completed(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")
lnbits_action = event_object.get("metadata", {}).get("lnbits_action")
alan_action = event_object.get("metadata", {}).get("alan_action")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if lnbits_action != "invoice":
logger.warning(f"Stripe event is not an invoice: '{lnbits_action}'.")
if alan_action != "invoice":
logger.warning(f"Stripe event is not an invoice: '{alan_action}'.")
return
if not payment_hash:
@@ -132,7 +166,7 @@ async def _get_stripe_subscription_payment_options(
metadata = parent.get("subscription_details", {}).get("metadata", {})
if metadata.get("lnbits_action") != "subscription":
if metadata.get("alan_action") != "subscription":
raise ValueError("Stripe invoice.paid metadata action is not 'subscription'.")
if "extra" in metadata:
@@ -143,3 +177,84 @@ async def _get_stripe_subscription_payment_options(
metadata["extra"] = {}
return FiatSubscriptionPaymentOptions(**metadata)
async def handle_paypal_event(event: dict):
event_type = event.get("event_type", "")
resource = event.get("resource", {})
if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"):
payment_hash = _paypal_extract_payment_hash(resource)
if not payment_hash:
logger.warning("PayPal event missing 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()
return
if event_type in (
"PAYMENT.SALE.COMPLETED",
"BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED",
):
await _handle_paypal_subscription_payment(resource)
return
logger.info(f"Unhandled PayPal event type: '{event_type}'.")
async def _handle_paypal_subscription_payment(resource: dict):
amount_info = resource.get("amount") or {}
currency = (amount_info.get("currency_code") or "").upper()
total = amount_info.get("value")
if not currency or total is None:
raise ValueError("PayPal subscription event missing amount.")
custom_id = resource.get("custom_id") or resource.get("custom")
if not custom_id:
raise ValueError("PayPal subscription event missing custom metadata.")
try:
metadata = json.loads(custom_id)
except json.JSONDecodeError:
metadata = {}
payment_options = FiatSubscriptionPaymentOptions(**metadata)
if not payment_options.wallet_id:
raise ValueError("PayPal subscription event missing wallet_id.")
memo = payment_options.memo or ""
extra = {
**(payment_options.extra or {}),
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": resource.get("id") or resource.get("billing_agreement_id"),
"payment_request": "",
},
}
payment = await create_fiat_invoice(
wallet_id=payment_options.wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=float(total),
memo=memo,
extra=extra,
fiat_provider="paypal",
),
)
await payment.check_fiat_status()
def _paypal_extract_payment_hash(resource: dict) -> str | None:
purchase_units = resource.get("purchase_units") or []
for pu in purchase_units:
if pu.get("invoice_id"):
return pu.get("invoice_id")
if pu.get("custom_id"):
return pu.get("custom_id")
return None
+119 -22
View File
@@ -7,9 +7,10 @@ from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import (
SimpleStatus,
User,
)
from lnbits.core.models.extensions import (
CreateExtension,
@@ -23,6 +24,7 @@ from lnbits.core.models.extensions import (
UserExtension,
UserExtensionInfo,
)
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.services.extensions import (
activate_extension,
@@ -33,15 +35,18 @@ from lnbits.core.services.extensions import (
uninstall_extension,
)
from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
check_admin,
check_user_exists,
)
from lnbits.settings import settings
from ..crud import (
create_user_extension,
delete_dbversion,
drop_extension_db,
get_db_version,
get_db_versions,
get_installed_extension,
get_installed_extensions,
get_user_extension,
@@ -140,9 +145,10 @@ async def api_extension_details(
async def api_update_pay_to_enable(
ext_id: str,
data: PayToEnableInfo,
user: User = Depends(check_admin),
account: Account = Depends(check_admin),
) -> SimpleStatus:
if data.wallet not in user.wallet_ids:
user_wallet_ids = await get_wallets_ids(account.id, deleted=False)
if data.wallet not in user_wallet_ids:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
)
@@ -161,7 +167,7 @@ async def api_update_pay_to_enable(
@extension_router.put("/{ext_id}/enable")
async def api_enable_extension(
ext_id: str, user: User = Depends(check_user_exists)
ext_id: str, account_id: AccountId = Depends(check_account_id_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException(
@@ -175,12 +181,12 @@ async def api_enable_extension(
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(user.id, ext_id)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
if user.admin or not ext.requires_payment:
if account_id.is_admin_id or not ext.requires_payment:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
@@ -217,13 +223,13 @@ async def api_enable_extension(
@extension_router.put("/{ext_id}/disable")
async def api_disable_extension(
ext_id: str, user: User = Depends(check_user_exists)
ext_id: str, account_id: AccountId = Depends(check_account_id_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
)
user_ext = await get_user_extension(user.id, ext_id)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled."
@@ -374,7 +380,9 @@ async def get_pay_to_install_invoice(
@extension_router.put("/{ext_id}/invoice/enable")
async def get_pay_to_enable_invoice(
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
ext_id: str,
data: PayToEnableInfo,
account_id: AccountId = Depends(check_account_id_exists),
):
if not data.amount or data.amount <= 0:
raise HTTPException(
@@ -420,9 +428,9 @@ async def get_pay_to_enable_invoice(
memo=f"Enable '{ext.name}' extension.",
)
user_ext = await get_user_extension(user.id, ext_id)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
user_ext_info.payment_hash_to_enable = payment.payment_hash
@@ -433,7 +441,7 @@ async def get_pay_to_enable_invoice(
@extension_router.get(
"/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_user_exists)],
dependencies=[Depends(check_account_exists)],
)
async def get_extension_release(org: str, repo: str, tag_name: str):
try:
@@ -454,15 +462,18 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
@extension_router.get("")
async def api_get_user_extensions(
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
) -> list[Extension]:
user_extensions_ids = [ue.extension for ue in await get_user_extensions(user.id)]
return [
ext
for ext in await get_valid_extensions(False)
if ext.code in user_extensions_ids
]
async with db.connect() as conn:
user_extensions_ids = [
ue.extension for ue in await get_user_extensions(account_id.id, conn=conn)
]
valid_extensions = [
ext
for ext in await get_valid_extensions(False, conn=conn)
if ext.code in user_extensions_ids
]
return valid_extensions
@extension_router.delete(
@@ -492,3 +503,89 @@ async def delete_extension_db(ext_id: str):
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Cannot delete data for extension '{ext_id}'",
) from exc
# TODO: create a response model for this
@extension_router.get("/all")
async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
async with db.connect() as conn:
installed_exts: list[InstallableExtension] = await get_installed_extensions(
conn=conn
)
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False, conn=conn)
]
db_versions = await get_db_versions(conn=conn)
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions(
post_refresh_cache=account_id.is_admin_id
)
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
extension_data = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
"inProgress": False,
"selectedForUpdate": False,
}
for ext in installable_exts
]
return extension_data
+13 -25
View File
@@ -1,14 +1,12 @@
import os
import shutil
from hashlib import sha256
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from lnbits.core.models import (
SimpleStatus,
User,
)
from lnbits.core.models.extensions import (
Extension,
@@ -17,6 +15,7 @@ from lnbits.core.models.extensions import (
UserExtension,
)
from lnbits.core.models.extensions_builder import ExtensionData
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services.extensions import (
activate_extension,
install_extension,
@@ -27,10 +26,10 @@ from lnbits.core.services.extensions_builder import (
zip_directory,
)
from lnbits.decorators import (
check_account_id_exists,
check_admin,
check_user_exists,
check_extension_builder,
)
from lnbits.settings import settings
from ..crud import (
create_user_extension,
@@ -47,19 +46,12 @@ extension_builder_router = APIRouter(
@extension_builder_router.post(
"/zip",
summary="Build and download extension zip.",
dependencies=[Depends(check_extension_builder)],
description="""
This endpoint generates a zip file for the extension based on the provided data.
""",
)
async def api_build_extension(
data: ExtensionData,
user: User = Depends(check_user_exists),
) -> FileResponse:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
async def api_build_extension(data: ExtensionData) -> FileResponse:
stub_ext_id = "extension_builder_stub" # todo: do not hardcode, fetch from manifest
release, build_dir = await build_extension_from_data(data, stub_ext_id)
@@ -92,9 +84,9 @@ async def api_build_extension(
)
async def api_deploy_extension(
data: ExtensionData,
user: User = Depends(check_admin),
account: Account = Depends(check_admin),
) -> SimpleStatus:
working_dir_name = "deploy_" + sha256(user.id.encode("utf-8")).hexdigest()
working_dir_name = "deploy_" + sha256(account.id.encode("utf-8")).hexdigest()
stub_ext_id = "extension_builder_stub"
release, build_dir = await build_extension_from_data(
data, stub_ext_id, working_dir_name
@@ -118,9 +110,9 @@ async def api_deploy_extension(
await activate_extension(Extension.from_installable_ext(ext_info))
user_ext = await get_user_extension(user.id, data.id)
user_ext = await get_user_extension(account.id, data.id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=data.id, active=True)
user_ext = UserExtension(user=account.id, extension=data.id, active=True)
await create_user_extension(user_ext)
elif not user_ext.active:
user_ext.active = True
@@ -132,18 +124,14 @@ async def api_deploy_extension(
@extension_builder_router.post(
"/preview",
summary="Build and preview the extension ui.",
dependencies=[Depends(check_extension_builder)],
)
async def api_preview_extension(
data: ExtensionData,
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
) -> SimpleStatus:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
stub_ext_id = "extension_builder_stub"
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
working_dir_name = "preview_" + sha256(account_id.id.encode("utf-8")).hexdigest()
await build_extension_from_data(data, stub_ext_id, working_dir_name)
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")
+23 -15
View File
@@ -75,20 +75,28 @@ async def cancel_subscription(
)
async def connection_token(provider: str):
fiat_provider = await get_fiat_provider(provider)
if provider == "stripe":
if not isinstance(fiat_provider, StripeWallet):
if not fiat_provider:
raise HTTPException(status_code=404, detail="Fiat provider not found")
if provider != "stripe":
raise HTTPException(
status_code=400,
detail=f"Connection tokens are not supported for provider '{provider}'.",
)
if not isinstance(fiat_provider, StripeWallet):
raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured"
)
try:
tok = await fiat_provider.create_terminal_connection_token()
secret = tok.get("secret")
if not secret:
raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured"
status_code=502, detail="Stripe returned no connection token"
)
try:
tok = await fiat_provider.create_terminal_connection_token()
secret = tok.get("secret")
if not secret:
raise HTTPException(
status_code=502, detail="Stripe returned no connection token"
)
return {"secret": secret}
except Exception as e:
raise HTTPException(
status_code=500, detail="Failed to create connection token"
) from e
return {"secret": secret}
except Exception as e:
raise HTTPException(
status_code=500, detail="Failed to create connection token"
) from e
+39 -342
View File
@@ -1,35 +1,29 @@
from hashlib import sha256
from http import HTTPStatus
from pathlib import Path
from typing import Annotated
from urllib.parse import urlencode, urlparse
import httpx
from fastapi import Cookie, Depends, Query, Request
from fastapi import Depends, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter
from lnurl import url_decode
from pydantic.types import UUID4
from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User
from lnbits.core.models.extensions import ExtensionMeta, InstallableExtension
from lnbits.core.services import create_invoice, create_user_account
from lnbits.core.services.extensions import get_valid_extensions
from lnbits.decorators import check_admin, check_user_exists
from lnbits.decorators import (
check_admin,
check_admin_ui,
check_extension_builder,
check_first_install,
check_user_exists,
)
from lnbits.helpers import check_callback_url, template_renderer
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_wallet,
get_db_versions,
get_installed_extensions,
get_user,
get_wallet,
)
from ..crud import get_user
generic_router = APIRouter(
tags=["Core NON-API Website Routes"], include_in_schema=False
@@ -41,251 +35,23 @@ async def favicon():
return RedirectResponse(settings.lnbits_qr_logo)
@generic_router.get("/", response_class=HTMLResponse)
async def home(request: Request, lightning: str = ""):
return template_renderer().TemplateResponse(
request, "core/index.html", {"lnurl": lightning}
)
@generic_router.get(
"/account",
response_class=HTMLResponse,
description="show account page",
)
async def account(
request: Request,
user: User = Depends(check_user_exists),
):
nostr_configured = settings.is_nostr_notifications_configured()
telegram_configured = settings.is_telegram_notifications_configured()
return template_renderer().TemplateResponse(
request,
"core/account.html",
{
"user": user.json(),
"nostr_configured": nostr_configured,
"telegram_configured": telegram_configured,
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/wallet",
response_class=HTMLResponse,
description="show wallet page",
)
async def get_user_wallet(
request: Request,
lnbits_last_active_wallet: Annotated[str | None, Cookie()] = None,
user: User = Depends(check_user_exists),
wal: UUID4 | None = Query(None),
):
if wal:
wallet = await get_wallet(wal.hex)
elif len(user.wallets) == 0:
wallet = await create_wallet(user_id=user.id)
user.wallets.append(wallet)
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
wallet = await get_wallet(lnbits_last_active_wallet)
else:
wallet = user.wallets[0]
if not wallet or wallet.deleted:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found",
)
if wallet.user != user.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your wallet.",
)
context = {
"user": user.json(),
"wallet": wallet.json(),
"wallet_name": wallet.name,
"currencies": allowed_currencies(),
"service_fee": settings.lnbits_service_fee,
"service_fee_max": settings.lnbits_service_fee_max,
"web_manifest": f"/manifest/{user.id}.webmanifest",
}
return template_renderer().TemplateResponse(
request,
"core/wallet.html",
{**context, "ajax": _is_ajax_request(request)},
)
@generic_router.get(
"/wallets",
response_class=HTMLResponse,
description="show wallets page",
)
async def wallets(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer().TemplateResponse(
request,
"core/wallets.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/first_install", response_class=HTMLResponse)
async def first_install(request: Request):
if not settings.first_install:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Super user account has already been configured.",
)
return template_renderer().TemplateResponse(
request,
"core/first_install.html",
)
@generic_router.get("/robots.txt", response_class=HTMLResponse)
async def robots():
data = """
User-agent: *
Disallow: /
"""
data = "User-agent: *\nDisallow: /"
return HTMLResponse(content=data, media_type="text/plain")
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_versions = await get_db_versions()
extension_data = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
# refresh user state. Eg: enabled extensions.
# TODO: refactor
# user = await get_user(user.id) or user
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"user": user.json(),
"extension_data": extension_data,
"extension_builder_enabled": user.admin
or settings.lnbits_extensions_builder_activate_non_admins,
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/extensions/builder", name="extensions builder", response_class=HTMLResponse
)
async def extensions_builder(request: Request, user: User = Depends(check_user_exists)):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
return template_renderer().TemplateResponse(
request,
"core/extensions_builder.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/extensions/builder/preview/{ext_id}",
name="extensions builder",
response_class=HTMLResponse,
dependencies=[Depends(check_extension_builder)],
)
async def extensions_builder_preview(
request: Request,
ext_id: str,
page_name: str | None = None,
user: User = Depends(check_user_exists),
):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
) -> HTMLResponse:
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html"
if page_name == "public_page":
@@ -310,8 +76,8 @@ async def extensions_builder_preview(
request,
"error.html",
{
"err": f"Extension {ext_id} not found",
"message": "Please 'Refresh Preview' first.",
"status_code": 404,
"message": f"Extension {ext_id} not found, refresh Preview.",
},
status_code=HTTPStatus.NOT_FOUND,
)
@@ -321,7 +87,6 @@ async def extensions_builder_preview(
html_file_path.as_posix(),
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@@ -330,6 +95,7 @@ async def extensions_builder_preview(
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
)
return response
@@ -359,7 +125,7 @@ async def manifest(request: Request, usr: str):
"src": (
settings.lnbits_custom_logo
if settings.lnbits_custom_logo
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png"
else "images/logos/lnbits.png"
),
"sizes": "512x512",
"type": "image/png",
@@ -426,104 +192,39 @@ async def manifest(request: Request, usr: str):
}
@generic_router.get("/node", response_class=HTMLResponse)
async def node(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_node_ui:
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
funding_source = get_funding_source()
_, balance = await funding_source.status()
@generic_router.get("/payments")
@generic_router.get("/wallet")
@generic_router.get("/wallet/{wallet_id}")
@generic_router.get("/wallets")
@generic_router.get("/account")
@generic_router.get("/extensions")
@generic_router.get("/users", dependencies=admin_ui_checks)
@generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks)
@generic_router.get("/admin", dependencies=admin_ui_checks)
@generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
return template_renderer().TemplateResponse(
request,
"node/index.html",
"index.html",
{
"user": user.json(),
"balance": balance,
"wallets": user.wallets[0].json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/node/public", response_class=HTMLResponse)
async def node_public(request: Request):
if not settings.lnbits_public_node_ui:
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
funding_source = get_funding_source()
_, balance = await funding_source.status()
return template_renderer().TemplateResponse(
request,
"node/public.html",
{
"balance": balance,
},
)
@generic_router.get("/admin", response_class=HTMLResponse)
async def admin_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_admin_ui:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
funding_source = get_funding_source()
_, balance = await funding_source.status()
return template_renderer().TemplateResponse(
request,
"admin/index.html",
{
"user": user.json(),
"balance": balance,
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/users", response_class=HTMLResponse)
async def users_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_admin_ui:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return template_renderer().TemplateResponse(
"users/index.html",
{
"request": request,
"user": user.json(),
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/audit", response_class=HTMLResponse)
async def audit_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_audit_enabled:
raise HTTPException(HTTPStatus.NOT_FOUND, "Audit not enabled")
return template_renderer().TemplateResponse(
"audit/index.html",
{
"request": request,
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/payments", response_class=HTMLResponse)
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
return template_renderer().TemplateResponse(
"payments/index.html",
{
"request": request,
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/")
@generic_router.get("/node/public")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse:
return template_renderer().TemplateResponse(request, "index.html", {"public": True})
@generic_router.get("/uuidv4/{hex_value}")
@@ -584,7 +285,3 @@ async def lnurlwallet(request: Request, lightning: str = ""):
return RedirectResponse(
f"/wallet?usr={account.id}&wal={wallet.id}",
)
def _is_ajax_request(request: Request):
return request.headers.get("X-Requested-With", None) == "XMLHttpRequest"
+10 -9
View File
@@ -6,16 +6,17 @@ from fastapi import (
Depends,
HTTPException,
)
from lnurl import LnurlResponseException
from lnurl import execute_login as lnurlauth
from lnurl import handle as lnurl_handle
from lnurl.models import (
from lnurl import (
LnurlAuthResponse,
LnurlErrorResponse,
LnurlException,
LnurlPayResponse,
LnurlResponseModel,
LnurlResponseException,
LnurlWithdrawResponse,
)
from lnurl import execute_login as lnurlauth
from lnurl import handle as lnurl_handle
from lnurl.models import LnurlResponseModel
from loguru import logger
from lnbits.core.models import Payment
@@ -23,7 +24,7 @@ from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
require_invoice_key,
require_base_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
@@ -38,7 +39,7 @@ async def _handle(lnurl: str) -> LnurlResponseModel:
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
except LnurlResponseException as exc:
except LnurlException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
@@ -47,7 +48,7 @@ async def _handle(lnurl: str) -> LnurlResponseModel:
@lnurl_router.get(
"/api/v1/lnurlscan/{code}",
dependencies=[Depends(require_invoice_key)],
dependencies=[Depends(require_base_invoice_key)],
deprecated=True,
response_model=LnurlPayResponse
| LnurlWithdrawResponse
@@ -63,7 +64,7 @@ async def api_lnurlscan(code: str) -> LnurlResponseModel:
@lnurl_router.post(
"/api/v1/lnurlscan",
dependencies=[Depends(require_invoice_key)],
dependencies=[Depends(require_base_invoice_key)],
response_model=LnurlPayResponse
| LnurlWithdrawResponse
| LnurlAuthResponse
+2 -3
View File
@@ -3,7 +3,6 @@ from http import HTTPStatus
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.settings import settings
@@ -154,7 +153,7 @@ async def api_get_payments(
) -> Page[NodePayment] | None:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
HTTPStatus.SERVICE_UNAVAILABLE,
detail="You can enable node transactions in the Admin UI",
)
return await node.get_payments(filters)
@@ -167,7 +166,7 @@ async def api_get_invoices(
) -> Page[NodeInvoice] | None:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
HTTPStatus.SERVICE_UNAVAILABLE,
detail="You can enable node transactions in the Admin UI",
)
return await node.get_invoices(filters)
+75 -39
View File
@@ -15,7 +15,10 @@ from lnbits import bolt11
from lnbits.core.crud.payments import (
get_payment_count_stats,
get_wallets_stats,
update_payment,
)
from lnbits.core.crud.users import get_account
from lnbits.core.db import db
from lnbits.core.models import (
CancelInvoice,
CreateInvoice,
@@ -32,14 +35,17 @@ from lnbits.core.models import (
SettleInvoice,
SimpleStatus,
)
from lnbits.core.models.users import User
from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import BaseWalletTypeInfo
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
check_account_id_exists,
parse_filters,
require_admin_key,
require_invoice_key,
require_base_admin_key,
require_base_invoice_key,
)
from lnbits.helpers import (
filter_dict_keys,
@@ -64,7 +70,6 @@ from ..services import (
perform_withdraw,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
)
payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
@@ -79,10 +84,9 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments(
key_info: WalletTypeInfo = Depends(require_invoice_key),
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(key_info.wallet.id)
return await get_payments(
wallet_id=key_info.wallet.id,
pending=True,
@@ -98,11 +102,10 @@ async def api_payments(
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_history(
key_info: WalletTypeInfo = Depends(require_invoice_key),
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(key_info.wallet.id)
return await get_payments_history(key_info.wallet.id, group, filters)
@@ -115,14 +118,14 @@ async def api_payments_history(
async def api_payments_counting_stats(
count_by: PaymentCountField = Query("tag"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
):
if user.admin:
if account_id.is_admin_id:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
for_user_id = account_id.id
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
@@ -135,14 +138,14 @@ async def api_payments_counting_stats(
)
async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
):
if user.admin:
if account_id.is_admin_id:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
for_user_id = account_id.id
return await get_wallets_stats(filters, user_id=for_user_id)
@@ -154,15 +157,15 @@ async def api_payments_wallets_stats(
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_daily_stats(
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
if user.admin:
if account_id.is_admin_id:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
for_user_id = account_id.id
return await get_payments_daily_stats(filters, user_id=for_user_id)
@@ -175,18 +178,30 @@ async def api_payments_daily_stats(
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_paginated(
key_info: WalletTypeInfo = Depends(require_invoice_key),
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
recheck_pending: bool = Query(
False, description="Force check and update of pending payments."
),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
page = await get_payments_paginated(
wallet_id=key_info.wallet.id,
filters=filters,
)
for payment in page.data:
if payment.pending:
await update_pending_payment(payment)
) -> Page[Payment]:
async with db.connect() as conn:
page = await get_payments_paginated(
wallet_id=key_info.wallet.id,
filters=filters,
conn=conn,
)
if not recheck_pending:
return page
return page
payments = []
for payment in page.data:
if payment.pending:
refreshed_payment = await update_pending_payment(payment, conn=conn)
payments.append(refreshed_payment)
else:
payments.append(payment)
return Page(data=payments, total=page.total)
@payment_router.get(
@@ -199,19 +214,19 @@ async def api_payments_paginated(
)
async def api_all_payments_paginated(
filters: Filters = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
):
if user.admin:
if account_id.is_admin_id:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
for_user_id = account_id.id
return await get_payments_paginated(
filters=filters,
user_id=for_user_id,
)
async with db.connect() as conn:
return await get_payments_paginated(
filters=filters, user_id=for_user_id, conn=conn
)
@payment_router.post(
@@ -234,10 +249,10 @@ async def api_all_payments_paginated(
)
async def api_payments_create(
invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
) -> Payment:
wallet_id = wallet.wallet.id
if invoice_data.out is True and wallet.key_type == KeyType.admin:
wallet_id = key_info.wallet.id
if invoice_data.out is True and key_info.key_type == KeyType.admin:
if not invoice_data.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -247,6 +262,7 @@ async def api_payments_create(
wallet_id=wallet_id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
labels=invoice_data.labels,
)
return payment
@@ -260,6 +276,26 @@ async def api_payments_create(
return await create_payment_request(wallet_id, invoice_data)
@payment_router.put("/{payment_hash}/labels")
async def api_update_payment_labels(
payment_hash: str,
data: UpdatePaymentLabels,
key_type: BaseWalletTypeInfo = Depends(require_base_admin_key),
) -> SimpleStatus:
payment = await get_standalone_payment(payment_hash, wallet_id=key_type.wallet.id)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
account = await get_account(key_type.wallet.user)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account does not exist.")
# only keep labels that belong to the user
user_label_names = [label.name for label in account.extra.labels]
payment.labels = [label for label in data.labels if label in user_label_names]
await update_payment(payment)
return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice)
@@ -301,7 +337,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
return {"paid": False, "status": "failed"}
try:
status = await payment.check_status()
payment = await update_pending_payment(payment)
except Exception:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}
@@ -310,7 +346,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
if wallet and wallet.id == payment.wallet_id:
return {
"paid": payment.success,
"status": f"{status!s}",
"status": f"{payment.status!s}",
"preimage": payment.preimage,
"details": payment,
}
+4 -3
View File
@@ -7,10 +7,11 @@ from fastapi import (
)
from starlette.responses import RedirectResponse
from lnbits.core.models.wallets import BaseWalletTypeInfo
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
require_invoice_key,
require_base_invoice_key,
)
from ..crud import (
@@ -50,12 +51,12 @@ async def api_create_tinyurl(
description="get a tinyurl by id",
)
async def api_get_tinyurl(
tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
tinyurl_id: str, key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key)
):
try:
tinyurl = await get_tinyurl(tinyurl_id)
if tinyurl:
if tinyurl.wallet == wallet.wallet.id:
if tinyurl.wallet == key_info.wallet.id:
return tinyurl
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
+6 -6
View File
@@ -115,12 +115,12 @@ async def api_create_user(data: CreateUser) -> CreateUser:
@users_router.put("/user/{user_id}", name="Update user")
async def api_update_user(
user_id: str, data: CreateUser, user: User = Depends(check_admin)
user_id: str, data: CreateUser, account: Account = Depends(check_admin)
) -> CreateUser:
if user_id != data.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
if user_id == settings.super_user and user.id != settings.super_user:
if user_id == settings.super_user and account.id != settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.",
@@ -154,7 +154,7 @@ async def api_update_user(
name="Delete user by Id",
)
async def api_users_delete_user(
user_id: str, user: User = Depends(check_admin)
user_id: str, account: Account = Depends(check_admin)
) -> SimpleStatus:
wallets = await get_wallets(user_id, deleted=False)
if len(wallets) > 0:
@@ -169,7 +169,7 @@ async def api_users_delete_user(
detail="Cannot delete super user.",
)
if user_id in settings.lnbits_admin_users and not user.super_user:
if user_id in settings.lnbits_admin_users and not account.is_super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Only super_user can delete admin user.",
@@ -295,7 +295,7 @@ async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
"The second time it is called will delete the entry from the DB",
)
async def api_users_delete_user_wallet(
user_id: str, wallet: str, user: User = Depends(check_admin)
user_id: str, wallet: str, account: Account = Depends(check_admin)
) -> SimpleStatus:
wal = await get_wallet(wallet)
if not wal:
@@ -304,7 +304,7 @@ async def api_users_delete_user_wallet(
detail="Wallet does not exist.",
)
if user_id == settings.super_user and user.id != settings.super_user:
if user_id == settings.super_user and account.id != settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.",
+85 -14
View File
@@ -8,21 +8,38 @@ from fastapi import (
HTTPException,
)
from lnbits.core.crud.wallets import get_wallets_paginated
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
from lnbits.core.crud.wallets import (
create_wallet,
get_wallets_paginated,
)
from lnbits.core.models import CreateWallet, KeyType, Wallet, WalletTypeInfo
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
from lnbits.core.models.wallets import WalletsFilters
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import Account, AccountId
from lnbits.core.models.wallets import (
WalletsFilters,
WalletSharePermission,
WalletType,
)
from lnbits.core.services.wallets import (
create_lightning_shared_wallet,
delete_wallet_share,
invite_to_wallet,
reject_wallet_invitation,
update_wallet_share_permissions,
)
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_user_exists,
check_account_exists,
check_account_id_exists,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.utils.cache import cache
from ..crud import (
create_wallet,
delete_wallet,
get_wallet,
update_wallet,
@@ -51,17 +68,46 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
openapi_extra=generate_filter_params_openapi(WalletsFilters),
)
async def api_wallets_paginated(
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
filters: Filters = Depends(parse_filters(WalletsFilters)),
):
page = await get_wallets_paginated(
user_id=user.id,
user_id=account_id.id,
filters=filters,
)
return page
@wallet_router.put("/share/invite")
async def api_invite_wallet_share(
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> WalletSharePermission:
return await invite_to_wallet(key_info.wallet, data)
@wallet_router.delete("/share/invite/{share_request_id}")
async def api_reject_wallet_invitation(
share_request_id: str, invited_user: Account = Depends(check_account_exists)
) -> SimpleStatus:
await reject_wallet_invitation(invited_user.id, share_request_id)
return SimpleStatus(success=True, message="Invitation rejected.")
@wallet_router.put("/share")
async def api_accept_wallet_share_request(
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> WalletSharePermission:
return await update_wallet_share_permissions(key_info.wallet, data)
@wallet_router.delete("/share/{share_request_id}")
async def api_delete_wallet_share_permissions(
share_request_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> SimpleStatus:
return await delete_wallet_share(key_info.wallet, share_request_id)
@wallet_router.put("/{new_name}")
async def api_update_wallet_name(
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
@@ -70,6 +116,7 @@ async def api_update_wallet_name(
if not wallet:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
wallet.name = new_name
await update_wallet(wallet)
return {
"id": wallet.id,
@@ -80,12 +127,17 @@ async def api_update_wallet_name(
@wallet_router.put("/reset/{wallet_id}")
async def api_reset_wallet_keys(
wallet_id: str, user: User = Depends(check_user_exists)
wallet_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> Wallet:
wallet = await get_wallet(wallet_id)
if not wallet or wallet.user != user.id:
if not wallet or wallet.user != account_id.id:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
cache.pop(f"auth:wallet:{wallet.id}")
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
wallet.adminkey = uuid4().hex
wallet.inkey = uuid4().hex
await update_wallet(wallet)
@@ -124,16 +176,17 @@ async def api_update_wallet(
wallet.extra.color = color or wallet.extra.color
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
wallet.currency = currency if currency is not None else wallet.currency
await update_wallet(wallet)
return wallet
@wallet_router.delete("/{wallet_id}")
async def api_delete_wallet(
wallet_id: str, user: User = Depends(check_user_exists)
wallet_id: str, account_id: AccountId = Depends(check_account_id_exists)
) -> None:
wallet = await get_wallet(wallet_id)
if not wallet or wallet.user != user.id:
if not wallet or wallet.user != account_id.id:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
await delete_wallet(
@@ -144,7 +197,25 @@ async def api_delete_wallet(
@wallet_router.post("")
async def api_create_wallet(
data: CreateWallet,
key_info: WalletTypeInfo = Depends(require_admin_key),
data: CreateWallet, account_id: AccountId = Depends(check_account_id_exists)
) -> Wallet:
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
if data.wallet_type not in list(WalletType):
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Wallet type {data.wallet_type} does not exist.",
)
if data.wallet_type == WalletType.LIGHTNING_SHARED:
if not data.shared_wallet_id:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Shared wallet ID is required for shared wallets.",
)
return await create_lightning_shared_wallet(
user_id=account_id.id,
source_wallet_id=data.shared_wallet_id,
)
# default WalletType.LIGHTNING:
return await create_wallet(user_id=account_id.id, wallet_name=data.name)
+7 -7
View File
@@ -15,9 +15,9 @@ from lnbits.core.models import (
CreateWebPushSubscription,
WebPushSubscription,
)
from lnbits.core.models.users import User
from lnbits.core.models.users import AccountId
from lnbits.decorators import (
check_user_exists,
check_account_id_exists,
)
from ..crud import (
@@ -33,20 +33,20 @@ webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
async def api_create_webpush_subscription(
request: Request,
data: CreateWebPushSubscription,
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
) -> WebPushSubscription:
try:
subscription = json.loads(data.subscription)
endpoint = subscription["endpoint"]
host = urlparse(str(request.url)).netloc
subscription = await get_webpush_subscription(endpoint, user.id)
subscription = await get_webpush_subscription(endpoint, account_id.id)
if subscription:
return subscription
else:
return await create_webpush_subscription(
endpoint,
user.id,
account_id.id,
data.subscription,
host,
)
@@ -61,13 +61,13 @@ async def api_create_webpush_subscription(
@webpush_router.delete("", status_code=HTTPStatus.OK)
async def api_delete_webpush_subscription(
request: Request,
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
):
try:
endpoint = unquote(
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
)
count = await delete_webpush_subscription(endpoint, user.id)
count = await delete_webpush_subscription(endpoint, account_id.id)
return {"count": count}
except Exception as exc:
logger.debug(exc)
+92 -25
View File
@@ -130,6 +130,12 @@ class Compat:
return "BIGINT"
return "INT"
@property
def blob(self) -> str:
if self.type in {POSTGRES}:
return "BYTEA"
return "BLOB"
def timestamp_placeholder(self, key: str) -> str:
return compat_timestamp_placeholder(key)
@@ -216,18 +222,34 @@ class Connection(Compat):
filters: Filters | None = None,
model: type[TModel] | None = None,
group_by: list[str] | None = None,
table_name: str | None = None,
) -> Page[TModel]:
"""
Parameters:
query: The main SQL query string to execute for data retrieval.
where: list of additional WHERE clause conditions to filter results.
values: dictionary of parameter values to be used in the SQL query.
filters: object for advanced filtering, sorting, and pagination logic.
model: pydantic model type to map query results into model instances.
group_by: list of column names to group results by in the SQL query.
table_name: if provided some optimisations can be applied.
"""
if not filters:
filters = Filters()
if table_name:
if not _valid_sql_name(table_name):
raise ValueError(f"Invalid table name: '{table_name}'.")
filters.set_table_name(table_name)
clause = filters.where(where)
parsed_values = filters.values(values)
group_by_string = ""
if group_by:
for field in group_by:
if not re.fullmatch(
r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", field
):
if not _valid_sql_name(field):
raise ValueError("Value for GROUP BY is invalid")
group_by_string = f"GROUP BY {', '.join(group_by)}"
@@ -245,16 +267,17 @@ class Connection(Compat):
if rows:
# no need for extra query if no pagination is specified
if filters.offset or filters.limit:
result = await self.execute(
f"""
SELECT COUNT(*) as count FROM (
if table_name:
count_query = f"SELECT COUNT(*) as count FROM {table_name} {clause}" # noqa: S608
else:
count_query = f"""SELECT COUNT(*) as count
FROM (
{query}
{clause}
{group_by_string}
) as count
""", # noqa: S608
parsed_values,
)
) as count""" # noqa: S608
result = await self.execute(count_query, parsed_values)
row = result.mappings().first()
result.close()
count = int(row.get("count", 0))
@@ -387,9 +410,12 @@ class Database(Compat):
filters: Filters | None = None,
model: type[TModel] | None = None,
group_by: list[str] | None = None,
table_name: str | None = None,
) -> Page[TModel]:
async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by)
return await conn.fetch_page(
query, where, values, filters, model, group_by, table_name
)
async def execute(self, query: str, values: dict | None = None):
async with self.connect() as conn:
@@ -425,6 +451,8 @@ class Operator(Enum):
INCLUDE = "in"
EXCLUDE = "ex"
LIKE = "like"
EVERY = "every"
ANY = "any"
@property
def as_sql(self):
@@ -444,7 +472,7 @@ class Operator(Enum):
return ">="
elif self == Operator.LE:
return "<="
elif self == Operator.LIKE:
elif self in {Operator.LIKE, Operator.EVERY, Operator.ANY}:
return "LIKE"
else:
raise ValueError("Unknown SQL Operator")
@@ -466,6 +494,7 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]):
table_name: str | None = None
field: str
op: Operator = Operator.EQ
model: type[TFilterModel] | None
@@ -475,6 +504,8 @@ class Filter(BaseModel, Generic[TFilterModel]):
def parse_query(
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
):
if i > 1000 or len(raw_values) > 1000:
raise ValueError("Too many filter values")
# Key format:
# key[operator]
# e.g. name[eq]
@@ -491,11 +522,14 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__:
compare_field = model.__fields__[field]
values: dict = {}
for raw_value in raw_values:
if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}:
raw_values = [v for rv in raw_values for v in rv.split(",")]
for index, raw_value in enumerate(raw_values):
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
values[f"{field}__{i}"] = validated
values[f"{field}__{index}"] = validated
else:
raise ValueError("Unknown filter field")
@@ -503,15 +537,24 @@ class Filter(BaseModel, Generic[TFilterModel]):
@property
def statement(self) -> str:
prefix = f"{self.table_name}." if self.table_name else ""
stmt = []
for key in self.values.keys() if self.values else []:
clean_key = key.split("__")[0]
if self.model and self.model.__fields__[clean_key].type_ == datetime:
if self.model and self.model.__fields__[self.field].type_ == datetime:
placeholder = compat_timestamp_placeholder(key)
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
stmt.append(f":{key}")
else:
placeholder = f":{key}"
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
return " OR ".join(stmt)
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
elif self.op == Operator.EVERY:
statement = " AND ".join(stmt)
else:
statement = " OR ".join(stmt)
return f"({statement})"
class Filters(BaseModel, Generic[TFilterModel]):
@@ -527,13 +570,14 @@ class Filters(BaseModel, Generic[TFilterModel]):
search: str | None = None
offset: int | None = None
limit: int | None = None
limit: int | None = 10
sortby: str | None = None
direction: Literal["asc", "desc"] | None = None
model: type[TFilterModel] | None = None
table_name: str | None = None
@root_validator(pre=True)
def validate_sortby(cls, values):
sortby = values.get("sortby")
@@ -548,8 +592,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
def pagination(self) -> str:
stmt = ""
if self.limit:
stmt += f"LIMIT {self.limit} "
self.limit = self.limit or 10
stmt += f"LIMIT {min(1000, self.limit)} "
if self.offset:
stmt += f"OFFSET {self.offset}"
return stmt
@@ -575,7 +619,8 @@ class Filters(BaseModel, Generic[TFilterModel]):
def order_by(self) -> str:
if self.sortby:
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
prefix = f"{self.table_name}." if self.table_name else ""
return f"ORDER BY {prefix}{self.sortby} {self.direction or 'asc'}"
return ""
def values(self, values: dict | None = None) -> dict:
@@ -587,12 +632,26 @@ class Filters(BaseModel, Generic[TFilterModel]):
for key, value in page_filter.values.items():
if page_filter.op == Operator.LIKE:
values[key] = f"%{value}%"
elif page_filter.op in {Operator.EVERY, Operator.ANY}:
values[key] = f"""%"{value}"%"""
else:
values[key] = value
if self.search and self.model:
values["search"] = f"%{self.search.lower()}%"
return values
def set_table_name(self, table_name: str) -> None:
self.table_name = table_name
for page_filter in self.filters:
page_filter.table_name = table_name
class DbJsonEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Enum):
return o.value
return super().default(o)
def insert_query(table_name: str, model: BaseModel) -> str:
"""
@@ -648,7 +707,7 @@ def model_to_dict(model: BaseModel) -> dict:
or type_ is dict
or get_origin(outertype_) is list
):
_dict[key] = json.dumps(value)
_dict[key] = json.dumps(value, cls=DbJsonEncoder)
continue
_dict[key] = value
@@ -722,3 +781,11 @@ def _safe_load_json(value: str) -> dict:
# DB is corrupted if it gets here
logger.error(f"Failed to decode JSON: '{value}'")
return {}
def _valid_sql_name(name: str) -> bool:
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
return (
re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", name)
is not None
)
+246 -53
View File
@@ -19,6 +19,8 @@ from lnbits.core.crud import (
get_wallet_for_key,
)
from lnbits.core.crud.users import get_user_access_control_lists
from lnbits.core.crud.wallets import get_base_wallet_for_key
from lnbits.core.db import db
from lnbits.core.models import (
AccessTokenPayload,
Account,
@@ -27,9 +29,12 @@ from lnbits.core.models import (
User,
WalletTypeInfo,
)
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import BaseWallet, BaseWalletTypeInfo
from lnbits.db import Connection, Filter, Filters, TFilterModel
from lnbits.helpers import normalize_path, path_segments
from lnbits.helpers import normalize_path, path_segments, sha256s
from lnbits.settings import AuthMethods, settings
from lnbits.utils.cache import cache
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="api/v1/auth",
@@ -52,7 +57,7 @@ api_key_query = APIKeyQuery(
)
class KeyChecker(SecurityBase):
class BaseKeyChecker(SecurityBase):
def __init__(
self,
api_key: str | None = None,
@@ -77,8 +82,7 @@ class KeyChecker(SecurityBase):
)
self.model: APIKey = openapi_model # type: ignore
async def __call__(self, request: Request) -> WalletTypeInfo:
def _extract_key_value(self, request):
key_value = (
self._api_key
if self._api_key
@@ -91,27 +95,88 @@ class KeyChecker(SecurityBase):
detail="No Api Key provided.",
)
wallet = await get_wallet_for_key(key_value)
return key_value
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found.",
)
request.scope["user_id"] = wallet.user
async def _extract_key_type(self, key_value: str, wallet: BaseWallet) -> KeyType:
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Invalid adminkey.",
)
await _check_user_extension_access(wallet.user, request["path"])
key_type = KeyType.admin if wallet.adminkey == key_value else KeyType.invoice
return key_type
class KeyChecker(BaseKeyChecker):
def __init__(
self,
api_key: str | None = None,
expected_key_type: KeyType | None = None,
):
super().__init__(api_key, expected_key_type)
async def __call__(self, request: Request) -> WalletTypeInfo:
key_value = self._extract_key_value(request)
async with db.connect() as conn:
wallet = await get_wallet_for_key(key_value, conn=conn)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found.",
)
request.scope["user_id"] = wallet.user
await _check_user_access(request, wallet.user, conn=conn)
key_type = await self._extract_key_type(key_value, wallet)
return WalletTypeInfo(key_type, wallet)
class LightKeyChecker(BaseKeyChecker):
def __init__(
self,
api_key: str | None = None,
expected_key_type: KeyType | None = None,
):
super().__init__(api_key, expected_key_type)
async def __call__(self, request: Request) -> BaseWalletTypeInfo:
key_value = self._extract_key_value(request)
cache_key = f"auth:x-api-key:{key_value}"
cache_time = settings.auth_authentication_cache_minutes * 60
async with db.connect() as conn:
if cache_time > 0:
key_info: BaseWalletTypeInfo | None = cache.get(cache_key)
if key_info:
request.scope["user_id"] = key_info.wallet.user
await _check_user_access(request, key_info.wallet.user, conn=conn)
return key_info
wallet = await get_base_wallet_for_key(key_value, conn=conn)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found.",
)
request.scope["user_id"] = wallet.user
await _check_user_access(request, wallet.user, conn=conn)
key_type = await self._extract_key_type(key_value, wallet)
key_info = BaseWalletTypeInfo(key_type, wallet)
if cache_time > 0:
cache.set(cache_key, key_info, expiry=cache_time)
cache.set(f"auth:wallet:{wallet.id}", wallet, expiry=cache_time)
return key_info
async def require_admin_key(
request: Request,
api_key_header: str = Security(api_key_header),
@@ -124,6 +189,18 @@ async def require_admin_key(
return await check(request)
async def require_base_admin_key(
request: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> BaseWalletTypeInfo:
check: LightKeyChecker = LightKeyChecker(
api_key=api_key_header or api_key_query,
expected_key_type=KeyType.admin,
)
return await check(request)
async def require_invoice_key(
request: Request,
api_key_header: str = Security(api_key_header),
@@ -136,6 +213,18 @@ async def require_invoice_key(
return await check(request)
async def require_base_invoice_key(
request: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> BaseWalletTypeInfo:
check: LightKeyChecker = LightKeyChecker(
api_key=api_key_header or api_key_query,
expected_key_type=KeyType.invoice,
)
return await check(request)
async def check_access_token(
header_access_token: Annotated[str | None, Depends(oauth2_scheme)],
cookie_access_token: Annotated[str | None, Cookie()] = None,
@@ -144,33 +233,95 @@ async def check_access_token(
return header_access_token or cookie_access_token or bearer_access_token
async def check_account_id_exists(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> AccountId:
cache_key: str | None = None
if access_token:
cache_key = f"auth:access_token:{sha256s(access_token)}"
elif usr:
cache_key = f"auth:user_id:{sha256s(usr.hex)}"
async with db.connect() as conn:
if cache_key and settings.auth_authentication_cache_minutes > 0:
account_id = cache.get(cache_key)
if account_id:
r.scope["user_id"] = account_id.id
await _check_user_access(r, account_id.id, conn=conn)
return account_id
account = await _check_account_exists(r, access_token, usr, conn=conn)
account_id = AccountId(id=account.id)
if cache_key and settings.auth_authentication_cache_minutes > 0:
cache.set(
cache_key,
account_id,
expiry=settings.auth_authentication_cache_minutes * 60,
)
return account_id
async def check_account_exists(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> Account:
return await _check_account_exists(r, access_token, usr)
async def _check_account_exists(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
conn: Connection | None = None,
) -> Account:
"""
Check that the account exists based on access token or user id.
More performant version of `check_user_exists`.
Unlike `check_user_exists`, this function:
- does not fetch the user wallets
- caches the account info based on settings cache time
"""
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
if access_token:
account = await _get_account_from_token(
access_token, r["path"], r["method"], conn=new_conn
)
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
account = await get_account(usr.hex, conn=new_conn)
if account and account.is_admin:
raise HTTPException(
HTTPStatus.FORBIDDEN, "User id only access for admins is forbidden."
)
else:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Missing user ID or access token."
)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
r.scope["user_id"] = account.id
await _check_user_access(r, account.id, conn=new_conn)
return account
async def check_user_exists(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> User:
if access_token:
account = await _get_account_from_token(access_token, r["path"], r["method"])
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
account = await get_account(usr.hex)
if account and account.is_admin:
raise HTTPException(
HTTPStatus.FORBIDDEN, "User id only access for admins is forbidden."
)
else:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
r.scope["user_id"] = account.id
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
user = await get_user_from_account(account)
async with db.connect() as conn:
account = await _check_account_exists(r, access_token, usr, conn=conn)
user = await get_user_from_account(account, conn=conn)
if not user:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
await _check_user_extension_access(user.id, r["path"])
return user
@@ -198,29 +349,36 @@ async def access_token_payload(
return AccessTokenPayload(**payload)
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
async def check_admin(
account: Annotated[Account, Depends(check_account_exists)],
) -> Account:
if (
account.id != settings.super_user
and account.id not in settings.lnbits_admin_users
):
raise HTTPException(
HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges."
)
if not user.has_password:
if not account.has_password:
raise HTTPException(
HTTPStatus.FORBIDDEN, "Admin users must have credentials configured."
)
return user
return account
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user:
async def check_super_user(
account: Annotated[Account, Depends(check_admin)],
) -> Account:
if account.id != settings.super_user:
raise HTTPException(
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
)
if not user.has_password:
if not account.has_password:
raise HTTPException(
HTTPStatus.FORBIDDEN, "Super user must have credentials configured."
)
return user
return account
def parse_filters(model: type[TFilterModel]):
@@ -280,9 +438,17 @@ async def check_user_extension_access(
return SimpleStatus(success=True, message="OK")
async def _check_user_extension_access(user_id: str, path: str):
async def _check_user_access(r: Request, user_id: str, conn: Connection | None = None):
if not settings.is_user_allowed(user_id):
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
await _check_user_extension_access(user_id, r["path"], conn=conn)
async def _check_user_extension_access(
user_id: str, path: str, conn: Connection | None = None
):
ext_id = path_segments(path)[0]
status = await check_user_extension_access(user_id, ext_id)
status = await check_user_extension_access(user_id, ext_id, conn=conn)
if not status.success:
raise HTTPException(
HTTPStatus.FORBIDDEN,
@@ -291,12 +457,12 @@ async def _check_user_extension_access(user_id: str, path: str):
async def _get_account_from_token(
access_token: str, path: str, method: str
access_token: str, path: str, method: str, conn: Connection | None = None
) -> Account | None:
try:
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
return await _get_account_from_jwt_payload(
AccessTokenPayload(**payload), path, method
AccessTokenPayload(**payload), path, method, conn=conn
)
except jwt.ExpiredSignatureError as exc:
@@ -309,33 +475,35 @@ async def _get_account_from_token(
async def _get_account_from_jwt_payload(
payload: AccessTokenPayload, path: str, method: str
payload: AccessTokenPayload, path: str, method: str, conn: Connection | None = None
) -> Account | None:
account = None
if payload.sub:
account = await get_account_by_username(payload.sub)
account = await get_account_by_username(payload.sub, conn=conn)
elif payload.usr:
account = await get_account(payload.usr)
account = await get_account(payload.usr, conn=conn)
elif payload.email:
account = await get_account_by_email(payload.email)
account = await get_account_by_email(payload.email, conn=conn)
if not account:
return None
if payload.api_token_id:
await _check_account_api_access(account.id, payload.api_token_id, path, method)
await _check_account_api_access(
account.id, payload.api_token_id, path, method, conn=conn
)
return account
async def _check_account_api_access(
user_id: str, token_id: str, path: str, method: str
user_id: str, token_id: str, path: str, method: str, conn: Connection | None = None
):
segments = path.split("/")
if len(segments) < 3:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not an API endpoint.")
acls = await get_user_access_control_lists(user_id)
acls = await get_user_access_control_lists(user_id, conn=conn)
acl = acls.get_acl_by_token_id(token_id)
if not acl:
raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.")
@@ -359,3 +527,28 @@ def url_for_interceptor(original_method):
# Upgraded extensions modify the path.
# This interceptor ensures that the path is normalized.
Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign]
async def check_admin_ui() -> None:
if not settings.lnbits_admin_ui:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Admin UI is disabled."
)
async def check_extension_builder(
user: Annotated[User, Depends(check_user_exists)],
) -> None:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
async def check_first_install():
if not settings.first_install:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Super user account has already been configured.",
)
+3 -1
View File
@@ -138,7 +138,9 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
@app.exception_handler(404)
async def error_handler_404(request: Request, exc: HTTPException):
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
if not request.url.path.endswith("routes.json"):
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
if not _is_browser_request(request):
return JSONResponse(
+3
View File
@@ -8,6 +8,7 @@ from loguru import logger
from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
from .paypal import PayPalWallet
from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat")
@@ -15,6 +16,7 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum):
stripe = "StripeWallet"
paypal = "PayPalWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None:
@@ -49,5 +51,6 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [
"PayPalWallet",
"StripeWallet",
]
+338
View File
@@ -0,0 +1,338 @@
import asyncio
import json
import time
from collections.abc import AsyncGenerator
from typing import Any
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
class PayPalCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
cancel_url: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class PayPalCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
checkout: PayPalCheckoutOptions | None = None
class PayPalWallet(FiatProvider):
"""https://developer.paypal.com/api/rest/"""
def __init__(self):
logger.debug("Initializing PayPalWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.paypal_api_endpoint:
raise ValueError("Cannot initialize PayPalWallet: missing endpoint.")
if not settings.paypal_client_id:
raise ValueError("Cannot initialize PayPalWallet: missing client id.")
if not settings.paypal_client_secret:
raise ValueError("Cannot initialize PayPalWallet: missing client secret.")
self.endpoint = normalize_endpoint(settings.paypal_api_endpoint)
self.headers = {
"User-Agent": f"PayPal Alan:{settings.version}",
}
self._access_token: str | None = None
self._token_expires_at: float = 0
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("PayPalWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing PayPal wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
await self._ensure_access_token()
return FiatStatusResponse(balance=0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if opts is None:
return FiatInvoiceResponse(ok=False, error_message="Invalid PayPal options")
try:
await self._ensure_access_token()
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message="Unable to authenticate."
)
co = opts.checkout or PayPalCheckoutOptions()
success_url = (
co.success_url
or settings.paypal_payment_success_url
or "https://lnbits.com"
)
cancel_url = co.cancel_url or success_url
order_data = {
"intent": "CAPTURE",
"purchase_units": [
{
"amount": {
"currency_code": currency.upper(),
"value": f"{amount:.2f}",
},
"custom_id": payment_hash[:127], # PayPal limit
"invoice_id": payment_hash[:127],
"description": memo or "LNbits Invoice",
}
],
"application_context": {
"return_url": success_url,
"cancel_url": cancel_url,
"shipping_preference": "NO_SHIPPING",
"user_action": "PAY_NOW",
},
}
try:
r = await self.client.post(
"/v2/checkout/orders", json=order_data, headers=self._auth_headers()
)
r.raise_for_status()
data = r.json()
order_id = data.get("id")
approval_url = self._get_approval_url(data.get("links") or [])
if not order_id or not approval_url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing id or approval url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"fiat_paypal_{order_id}",
payment_request=approval_url,
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
success_url = (
payment_options.success_url
or settings.paypal_payment_success_url
or "https://lnbits.com"
)
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
payment_options.extra = payment_options.extra or {}
payment_options.extra["subscription_request_id"] = (
payment_options.subscription_request_id
)
try:
await self._ensure_access_token()
payload = {
"plan_id": subscription_id,
"quantity": str(quantity),
"custom_id": self._serialize_metadata(payment_options),
"application_context": {
"return_url": success_url,
"cancel_url": success_url,
},
}
r = await self.client.post(
"/v1/billing/subscriptions",
json=payload,
headers=self._auth_headers(),
)
r.raise_for_status()
data = r.json()
approval_url = self._get_approval_url(data.get("links") or [])
if not approval_url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing approval url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=approval_url,
subscription_request_id=payment_options.subscription_request_id,
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
await self._ensure_access_token()
r = await self.client.post(
f"/v1/billing/subscriptions/{subscription_id}/cancel",
json={"reason": f"Cancelled by {correlation_id}"},
headers=self._auth_headers(),
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("PayPal does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
await self._ensure_access_token()
order_id = self._normalize_paypal_id(checking_id)
r = await self.client.get(
f"/v2/checkout/orders/{order_id}", headers=self._auth_headers()
)
r.raise_for_status()
return self._status_from_order(r.json())
except Exception as exc:
logger.debug(f"Error getting PayPal order status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("PayPal does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"PayPal does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("status") or "").upper()
if status in ["COMPLETED", "APPROVED"]:
return FiatPaymentSuccessStatus()
if status in ["VOIDED", "CANCELLED", "CANCELED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _normalize_paypal_id(self, checking_id: str) -> str:
return (
checking_id.replace("fiat_paypal_", "", 1)
if checking_id.startswith("fiat_paypal_")
else checking_id
)
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
) -> str:
md = {
"wallet_id": payment_options.wallet_id,
"memo": payment_options.memo,
"extra": payment_options.extra,
"tag": payment_options.tag,
"subscription_request_id": payment_options.subscription_request_id,
}
raw = json.dumps(md)
return raw[:127] # PayPal custom_id limit
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> PayPalCreateInvoiceOptions | None:
try:
return PayPalCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid PayPal options: {e}")
return None
async def _ensure_access_token(self):
if self._access_token and time.time() < self._token_expires_at:
return
r = await self.client.post(
"/v1/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(settings.paypal_client_id or "", settings.paypal_client_secret or ""),
headers={"Accept": "application/json"},
)
r.raise_for_status()
data = r.json()
token = data.get("access_token")
expires_in = int(data.get("expires_in") or 300)
if not token:
raise ValueError("Unable to retrieve PayPal access token.")
self._access_token = token
self._token_expires_at = time.time() + expires_in - 30
def _auth_headers(self) -> dict[str, str]:
return {**self.headers, "Authorization": f"Bearer {self._access_token}"}
def _get_approval_url(self, links: list[dict[str, Any]]) -> str | None:
for link in links:
if link.get("rel") == "approve":
return link.get("href")
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.paypal_api_endpoint),
str(settings.paypal_client_id),
str(settings.paypal_client_secret),
str(settings.paypal_webhook_id),
]
)
+23 -2
View File
@@ -35,6 +35,7 @@ class StripeTerminalOptions(BaseModel):
capture_method: Literal["automatic", "manual"] = "automatic"
metadata: dict[str, str] = Field(default_factory=dict)
reader_id: str | None = None
class StripeCheckoutOptions(BaseModel):
@@ -170,7 +171,7 @@ class StripeWallet(FiatProvider):
("line_items[0][price]", subscription_id),
("line_items[0][quantity]", f"{quantity}"),
]
subscription_data = {**payment_options.dict(), "lnbits_action": "subscription"}
subscription_data = {**payment_options.dict(), "alan_action": "subscription"}
subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {})
form_data += self._encode_metadata(
@@ -295,6 +296,15 @@ class StripeWallet(FiatProvider):
r.raise_for_status()
return r.json()
async def _process_terminal_payment_intent(
self, reader_id: str, payment_intent_id: str
) -> None:
data = {"payment_intent": payment_intent_id}
r = await self.client.post(
f"/v1/terminal/readers/{reader_id}/process_payment_intent", data=data
)
r.raise_for_status()
async def _create_checkout_invoice(
self,
amount_cents: int,
@@ -315,7 +325,7 @@ class StripeWallet(FiatProvider):
("mode", "payment"),
("success_url", success_url),
("metadata[payment_hash]", payment_hash),
("metadata[lnbits_action]", "invoice"),
("metadata[alan_action]", "invoice"),
("line_items[0][price_data][currency]", currency.lower()),
("line_items[0][price_data][product_data][name]", line_item_name),
("line_items[0][price_data][unit_amount]", str(amount_cents)),
@@ -378,6 +388,17 @@ class StripeWallet(FiatProvider):
ok=False,
error_message="Error: missing PaymentIntent or client_secret",
)
if term.reader_id:
try:
await self._process_terminal_payment_intent(term.reader_id, pi_id)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False,
error_message=(
"Error: unable to process PaymentIntent on reader"
),
)
return FiatInvoiceResponse(
ok=True, checking_id=pi_id, payment_request=client_secret
)
+28 -17
View File
@@ -18,6 +18,7 @@ from pydantic.schema import field_schema
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from lnbits.utils.exchange_rates import currencies
from .db import FilterModel
@@ -67,9 +68,10 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
folders.extend(additional_folders)
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
t.env.globals["static_url_for"] = static_url_for
t.env.globals["normalize_path"] = normalize_path
window_settings = {
"AD_SPACE": settings.lnbits_ad_space.split(","),
"AD_SPACE": settings.lnbits_ad_space,
"AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
"AD_SPACE_TITLE": settings.lnbits_ad_space_title,
"EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
@@ -90,6 +92,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,
"LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
"LNBITS_APPLE_TOUCH_ICON": settings.lnbits_apple_touch_icon,
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
@@ -97,15 +100,21 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
"LNBITS_VERSION": settings.version,
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
"USE_DEFAULT_REACTION": settings.lnbits_default_reaction,
"USE_DEFAULT_THEME": settings.lnbits_default_theme,
"USE_DEFAULT_BORDER": settings.lnbits_default_border,
"USE_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
"LNBITS_DEFAULT_THEME": settings.lnbits_default_theme,
"LNBITS_DEFAULT_BORDER": settings.lnbits_default_border,
"LNBITS_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
"LNBITS_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
"LNBITS_DENOMINATION": settings.lnbits_denomination,
"has_holdinvoice": settings.has_holdinvoice,
"LNBITS_NOSTR_CONFIGURED": settings.is_nostr_notifications_configured(),
"LNBITS_TELEGRAM_CONFIGURED": settings.is_telegram_notifications_configured(),
"LNBITS_EXT_BUILDER": settings.lnbits_extensions_builder_activate_non_admins,
"LNBITS_CURRENCIES": list(currencies.keys()),
"LNBITS_ALLOWED_CURRENCIES": settings.lnbits_allowed_currencies,
"CACHE_KEY": settings.server_startup_time,
}
t.env.globals["WINDOW_SETTINGS"] = window_settings
@@ -198,6 +207,11 @@ def is_valid_username(username: str) -> bool:
return re.fullmatch(username_regex, username) is not None
def is_valid_label(label: str) -> bool:
label_regex = r"([A-Za-z0-9 ._-]{1,100}$)"
return re.fullmatch(label_regex, label) is not None
def is_valid_external_id(external_id: str) -> bool:
if len(external_id) > 256:
return False
@@ -353,17 +367,6 @@ def normalize_path(path: str | None) -> str:
return "/" + "/".join(path_segments(path))
def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
image_folder = Path(settings.lnbits_data_folder, directory)
file_path = image_folder / filename
# Prevent dir traversal attack
if image_folder.resolve() not in file_path.resolve().parents:
raise ValueError("Unsafe filename.")
# Prevent filename with subdirectories
file_path = image_folder / filename.split("/")[-1]
return file_path.resolve()
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
if add_proto:
@@ -397,3 +400,11 @@ def is_snake_case(v: str) -> bool:
def lowercase_first_letter(s: str) -> str:
return s[:1].lower() + s[1:] if s else s
def sha256s(value: str) -> str:
"""
SHA256 applied on a string value.
Returns the hex as a string.
"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()
+44 -7
View File
@@ -270,6 +270,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_allowed_currencies: list[str] = Field(default=[])
lnbits_default_accounting_currency: str | None = Field(default=None)
lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png")
lnbits_apple_touch_icon: str | None = Field(default=None)
lnbits_default_reaction: str = Field(default="confettiBothSides")
lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border")
@@ -280,8 +281,11 @@ class ThemesSettings(LNbitsSettings):
class OpsSettings(LNbitsSettings):
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
lnbits_hide_api: bool = Field(default=False)
lnbits_upload_size_bytes: int = Field(default=512_000, ge=0) # 500kb
lnbits_upload_allowed_types: list[str] = Field(
class AssetSettings(LNbitsSettings):
lnbits_max_asset_size_mb: float = Field(default=2.5, ge=0.0)
lnbits_assets_allowed_mime_types: list[str] = Field(
default=[
"image/png",
"image/jpeg",
@@ -297,6 +301,18 @@ class OpsSettings(LNbitsSettings):
"heics",
]
)
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
lnbits_asset_thumbnail_height: int = Field(default=128, ge=0)
lnbits_asset_thumbnail_format: str = Field(default="png")
lnbits_max_assets_per_user: int = Field(default=1, ge=0)
lnbits_assets_no_limit_users: list[str] = Field(default=[])
def is_unlimited_assets_user(self, user_id: str) -> bool:
return (
settings.is_admin_user(user_id)
or user_id in self.lnbits_assets_no_limit_users
)
class FeeSettings(LNbitsSettings):
@@ -429,7 +445,7 @@ class NotificationsSettings(LNbitsSettings):
lnbits_notification_settings_update: bool = Field(default=True)
lnbits_notification_credit_debit: bool = Field(default=True)
notification_balance_delta_changed: bool = Field(default=True)
notification_balance_delta_threshold_sats: int = Field(default=1, ge=0)
lnbits_notification_server_start_stop: bool = Field(default=True)
lnbits_notification_watchdog: bool = Field(default=False)
lnbits_notification_server_status_hours: int = Field(default=24, gt=0)
@@ -591,7 +607,6 @@ class BreezLiquidSdkFundingSource(LNbitsSettings):
class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
boltz_client_macaroon: str | None = Field(default=None)
boltz_client_wallet: str | None = Field(default="lnbits")
boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None)
@@ -632,6 +647,20 @@ class StripeFiatProvider(LNbitsSettings):
stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class PayPalFiatProvider(LNbitsSettings):
paypal_enabled: bool = Field(default=False)
paypal_api_endpoint: str = Field(default="https://api-m.paypal.com")
paypal_client_id: str | None = Field(default=None)
paypal_client_secret: str | None = Field(default=None)
paypal_payment_success_url: str = Field(default="https://lnbits.com")
paypal_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/paypal"
)
paypal_webhook_id: str | None = Field(default=None)
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -667,7 +696,7 @@ class FundingSourcesSettings(
funding_source_max_retries: int = Field(default=4, ge=0)
class FiatProvidersSettings(StripeFiatProvider):
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
"""
Checks if a specific fiat provider is enabled.
@@ -676,7 +705,8 @@ class FiatProvidersSettings(StripeFiatProvider):
return False
if provider == "stripe":
return self.stripe_enabled
# Add checks for other fiat providers here as needed
if provider == "paypal":
return self.paypal_enabled
return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
@@ -690,7 +720,12 @@ class FiatProvidersSettings(StripeFiatProvider):
):
allowed_providers.append("stripe")
# Add other fiat providers here as needed
if self.paypal_enabled and (
not self.paypal_limits.allowed_users
or user_id in self.paypal_limits.allowed_users
):
allowed_providers.append("paypal")
return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
@@ -747,6 +782,7 @@ class AuthSettings(LNbitsSettings):
# How many seconds after login the user is allowed to update its credentials.
# A fresh login is required afterwards.
auth_credetials_update_threshold: int = Field(default=120, gt=0)
auth_authentication_cache_minutes: int = Field(default=10, ge=0)
def is_auth_method_allowed(self, method: AuthMethods):
return method.value in self.auth_allowed_methods
@@ -867,6 +903,7 @@ class EditableSettings(
ExtensionsSettings,
ThemesSettings,
OpsSettings,
AssetSettings,
FeeSettings,
ExchangeProvidersSettings,
SecuritySettings,
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+17 -17
View File
File diff suppressed because one or more lines are too long
+78
View File
@@ -0,0 +1,78 @@
<template>
<div class="text-center q-pa-md flex flex-center">
<div>
<div class="error-code" v-text="code"></div>
<div class="error-message" v-text="message"></div>
<div class="q-mx-auto q-mt-lg justify-center" style="width: max-content">
<q-btn
v-if="isExtension"
color="primary"
@click="goToExtension()"
label="Go To Extension"
></q-btn>
<q-btn
v-else-if="g.isUserAuthorized"
color="primary"
@click="goToWallet()"
label="Go to Wallet"
></q-btn>
<q-btn v-else color="primary" @click="goBack()" label="Go Back"></q-btn>
<span class="q-mx-md">OR</span>
<q-btn color="secondary" @click="goHome()" label="Go Home"></q-btn>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['dynamic', 'code', 'message'],
computed: {
isExtension() {
if (this.code != 403) return false
if (this.message.startsWith('Extension ')) return true
}
},
methods: {
goBack() {
window.history.back()
},
goHome() {
window.location = '/'
},
goToWallet() {
if (this.dynamic) {
this.$router.push('/wallet')
return
}
window.location = '/wallet'
},
goToExtension() {
const extension = this.message.match(/'([^']+)'/)[1]
const url = `/extensions#${extension}`
if (this.dynamic) {
this.$router.push(url)
return
}
window.location = url
},
async logOut() {
try {
await LNbits.api.logout()
window.location = '/'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
},
async created() {
// check if we have error from error.html
if (!this.dynamic) {
if (this.code == 401) {
console.warn(`Unauthorized: ${this.errorMessage}`)
this.logOut()
return
}
}
}
}
</script>
+3
View File
@@ -0,0 +1,3 @@
import LnbitsError from './lnbits-error.vue'
window.LnbitsError = LnbitsError

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