Compare commits

..
Author SHA1 Message Date
Arc 2e71830817 clean 2026-02-25 22:24:22 +00:00
Arc 0fb2334181 fundle 2026-02-25 22:08:00 +00:00
Arc 94c7796dd5 Merge remote-tracking branch 'origin/dev' into wasm_extensions 2026-02-25 21:30:24 +00:00
Arc af4ce8da52 pull almost all out to a parent extension 2026-02-25 21:05:28 +00:00
Arc 4e8a5e050b make 2026-02-25 16:20:26 +00:00
Arc 3fd7aae10d tasks and docs 2026-02-25 16:11:14 +00:00
Arc 64ad19a65b tags 2026-02-25 13:39:26 +00:00
dni ⚡ f93efbfb3a chore: update to version 1.5.0 (#3813) 2026-02-25 07:54:35 +01:00
dni ⚡ 4429c0cdb6 chore: update to v1.5.0-rc2 (#3806) 2026-02-25 07:54:33 +01:00
Vlad Stananddni ⚡ 2bdb89b4b9 fix: paypal subscriptions (#3797) 2026-02-25 07:54:31 +01:00
dni ⚡ 75754598e1 feat: restructure docker images, add sparkl2 docker image (#3794) 2026-02-25 07:54:30 +01:00
dni ⚡ b25a3c2bb4 feat: hide first_install_token if is not required (#3795) 2026-02-25 07:54:28 +01:00
dni ⚡ e7c4de6506 chore: update lnbits to version v1.5.0-rc1 (#3793) 2026-02-25 07:54:27 +01:00
c102f10fab feat: Spark L2 (#3715)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-25 07:54:18 +01:00
dependabot[bot]dni ⚡dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7c28b58aaa chore(deps): bump pillow from 12.1.0 to 12.1.1 (#3785)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 07:54:16 +01:00
dependabot[bot]dni ⚡dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Vlad Stan
2b27f13fa8 chore(deps): bump axios from 1.13.2 to 1.13.5 (#3786)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-25 07:54:15 +01:00
130f4fd0bc feat: modern theme stuff like rounded corners and card gradient (#3766)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-25 07:54:14 +01:00
dni ⚡ 4894b70bad fix: /first_install new superuser may duplicate an username (#3787) 2026-02-25 07:54:13 +01:00
Arcanddni ⚡ 05769240d3 fix: fail closed on PayPal webhook verification errors (#3769) 2026-02-25 07:54:11 +01:00
krvianddni ⚡ 7bd127d22c fix: use canonical values in rate limit unit select (#3753) 2026-02-25 07:54:10 +01:00
Vlad Stananddni ⚡ 6dc3df148d fix: validation of invitation code (#3767) 2026-02-25 07:54:09 +01:00
Vlad Stananddni ⚡ e286546b63 [feat] User invitation code (#3761) 2026-02-25 07:54:07 +01:00
Vlad Stananddni ⚡ eae1be1db8 [feat] Allow no exchange providers (#3763) 2026-02-25 07:54:06 +01:00
dni ⚡ 25ab676a3f fix: datetime in db.py added utc offset (#3762) 2026-02-25 07:54:05 +01:00
Vlad Stananddni ⚡ 2fd6648a9a fix: handle validation errors when the response is binary (#3765) 2026-02-25 07:54:03 +01:00
Vlad Stananddni ⚡ 8eda653e3f fix: extension paid/free label (#3764) 2026-02-25 07:54:01 +01:00
Vlad Stananddni ⚡ 7e327b2c6a [feat] User activation (#3749) 2026-02-25 07:54:00 +01:00
Arcanddni ⚡ 40c055e373 feat: FIRST_INSTALL_TOKEN to help services like Umbrel secure the first_install endpoint (#3751)
Co-authored-by: dni  <office@dnilabs.com>
2026-02-25 07:53:59 +01:00
dni ⚡andVlad Stan 1d9808b269 refactor: payment.check_status() into check_payment_status(payment) (#3747)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-25 07:53:57 +01:00
dni ⚡ c65ab2af87 fix: uiCustomisation JS Error (#3752) 2026-02-25 07:53:56 +01:00
dni ⚡ 15f093c136 refactor: g.settings with a PublicSettings class for mapping, for reactive WINDOW_SETTINGS (#3644) 2026-02-25 07:53:55 +01:00
Vlad Stananddni ⚡ 5311b0a393 feat: wallet featured button (#3740) 2026-02-25 07:53:54 +01:00
Vlad Stananddni ⚡ e72396fe31 [feat] persist user ui customization (#3743) 2026-02-25 07:53:52 +01:00
dni ⚡ d6d0ebb20e chore: remove ecdsa in favor of coincurve (#3746) 2026-02-25 07:53:51 +01:00
dni ⚡ 563dd5b499 CI: use uv for publishing on pypi (#3745) 2026-02-25 07:53:49 +01:00
Vlad Stananddni ⚡ 9be894da74 [feat] admin impersonate user (#3741) 2026-02-25 07:53:47 +01:00
Vlad Stananddni ⚡ 87b66c554a fix: DB migration conflict (#3739) 2026-02-25 07:53:46 +01:00
dni ⚡ 314b533e39 chore: update python packages (#3707) 2026-02-25 07:53:44 +01:00
Vlad Stananddni ⚡ af8f3382c1 feat: allow text upload (#3738) 2026-02-25 07:53:42 +01:00
Vlad Stananddni ⚡ 67bb86c1ce fix: better validation error messages (#3736) 2026-02-25 07:53:41 +01:00
dni ⚡ f0f9e41f99 chore: remove windowMixin from components and pages (#3632) 2026-02-25 07:53:39 +01:00
dependabot[bot]dni ⚡dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2fbaf57b5e chore(deps): bump cryptography from 42.0.8 to 44.0.1 (#3735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 07:53:37 +01:00
dependabot[bot]dni ⚡dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
87afba2abb chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 07:53:36 +01:00
dependabot[bot]dni ⚡dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c375bf1b15 chore(deps): bump urllib3 from 2.5.0 to 2.6.3 (#3733)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 07:53:34 +01:00
Djuri Baarsanddni ⚡ dbcc920d0a [fix] Fix missing timezone information 2026-02-25 07:53:32 +01:00
Vlad Stananddni ⚡ 2c61e219b8 [feat] Update extension builder for 1.4.1 (#3725)
Co-authored-by: dni  <office@dnilabs.com>
2026-02-25 07:53:31 +01:00
dependabot[bot]dni ⚡dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6cb9b7da1f chore(deps): bump werkzeug from 3.1.3 to 3.1.5 (#3732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 07:53:30 +01:00
Arc ee6ff7179e bug fixes 2026-02-22 01:19:19 +00:00
Arc 684dfcc9fb for uninstalling wasm 2026-02-21 20:47:18 +00:00
Arc a498d4a877 show keys 2026-02-21 14:48:41 +00:00
Arc ef90c983f2 make 2026-02-21 14:10:05 +00:00
Arc da8a82c78e black 2026-02-21 13:48:27 +00:00
Arc 51f98e8130 added websocket listener back 2026-02-21 12:39:27 +00:00
Arc 7b4436b288 not needed 2026-02-21 11:55:31 +00:00
Arc ec88cf483e path proxy 2026-02-21 11:26:38 +00:00
Arc ca215063be uv lock 2026-02-21 09:51:10 +00:00
Arc 2674cfb5f1 whoops, forgot to add 2026-02-21 09:26:20 +00:00
Arc 26f0b69085 init 2026-02-21 09:18:13 +00:00
dni ⚡andGitHub 2069e3de8b chore: update to v1.5.0-rc2 (#3806) 2026-02-20 10:04:59 +01:00
Vlad StanandGitHub 800be6042f fix: paypal subscriptions (#3797) 2026-02-20 10:56:08 +02:00
dni ⚡andGitHub c65d373f23 feat: restructure docker images, add sparkl2 docker image (#3794) 2026-02-18 13:52:19 +01:00
dni ⚡andGitHub 087e095bcf feat: hide first_install_token if is not required (#3795) 2026-02-18 13:52:02 +01:00
dni ⚡andGitHub 027a161494 chore: update lnbits to version v1.5.0-rc1 (#3793) 2026-02-17 12:26:50 +01:00
83ef05696e feat: Spark L2 (#3715)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-17 12:25:38 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
42962308e6 chore(deps): bump pillow from 12.1.0 to 12.1.1 (#3785)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-17 11:33:28 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Vlad Stan
783ee256a1 chore(deps): bump axios from 1.13.2 to 1.13.5 (#3786)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-17 11:32:58 +01:00
4e805fd00e feat: modern theme stuff like rounded corners and card gradient (#3766)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-16 08:06:36 +01:00
dni ⚡andGitHub f54824cc44 fix: /first_install new superuser may duplicate an username (#3787) 2026-02-13 14:44:52 +00:00
ArcandGitHub 768d2cbe2a fix: fail closed on PayPal webhook verification errors (#3769) 2026-02-10 08:50:50 +01:00
krviandGitHub efd36d0221 fix: use canonical values in rate limit unit select (#3753) 2026-02-09 10:43:03 +02:00
Vlad StanandGitHub 4684939e13 fix: validation of invitation code (#3767) 2026-02-05 18:53:01 +02:00
Vlad StanandGitHub 193ee20da4 [feat] User invitation code (#3761) 2026-02-05 12:35:24 +02:00
Vlad StanandGitHub 2fe7ab4f83 [feat] Allow no exchange providers (#3763) 2026-02-05 11:11:48 +01:00
dni ⚡andGitHub 10c8ca4ca1 fix: datetime in db.py added utc offset (#3762) 2026-02-05 11:05:58 +01:00
Vlad StanandGitHub f079762b71 fix: handle validation errors when the response is binary (#3765) 2026-02-05 11:55:40 +02:00
Vlad StanandGitHub 2e042d2597 fix: extension paid/free label (#3764) 2026-02-05 11:32:46 +02:00
Vlad StanandGitHub 49b57c9f0b [feat] User activation (#3749) 2026-02-05 11:31:47 +02:00
dni ⚡andGitHub 656c6cac5b hotfix: websocket with old balance was sent. (#3759) 2026-02-03 12:23:49 +01:00
dni ⚡andGitHub 83334d67c8 HOTFIX: websocket with old balance was sent. (#3758) 2026-02-03 12:12:33 +01:00
45e773b66f feat: FIRST_INSTALL_TOKEN to help services like Umbrel secure the first_install endpoint (#3751)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-30 11:21:52 +01:00
f692ac6f12 refactor: payment.check_status() into check_payment_status(payment) (#3747)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-30 11:18:17 +01:00
dni ⚡andGitHub 78202a027b fix: uiCustomisation JS Error (#3752) 2026-01-30 09:27:54 +01:00
dni ⚡andGitHub 28fa0906a6 refactor: g.settings with a PublicSettings class for mapping, for reactive WINDOW_SETTINGS (#3644) 2026-01-30 09:19:01 +01:00
Vlad StanandGitHub 91354f8be4 feat: wallet featured button (#3740) 2026-01-29 10:49:19 +02:00
Vlad StanandGitHub 5f5d45e89f [feat] persist user ui customization (#3743) 2026-01-29 10:35:05 +02:00
dni ⚡andGitHub 52bb125a25 chore: remove ecdsa in favor of coincurve (#3746) 2026-01-29 10:02:16 +02:00
dni ⚡andGitHub 2ca1ed897c CI: use uv for publishing on pypi (#3745) 2026-01-29 08:54:42 +01:00
Vlad StanandGitHub 631f4e8455 [feat] admin impersonate user (#3741) 2026-01-28 16:59:23 +01:00
Vlad StanandGitHub b22f88b264 fix: DB migration conflict (#3739) 2026-01-27 08:31:38 +02:00
dni ⚡andGitHub c08bc02930 chore: update python packages (#3707) 2026-01-26 08:54:37 +01:00
Vlad StanandGitHub e020a7c5a0 feat: allow text upload (#3738) 2026-01-26 09:43:52 +02:00
Vlad StanandGitHub d2e8914835 fix: better validation error messages (#3736) 2026-01-21 14:28:57 +02:00
dni ⚡andGitHub 5e79ca35ab chore: remove windowMixin from components and pages (#3632) 2026-01-21 11:26:45 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4690d178a2 chore(deps): bump cryptography from 42.0.8 to 44.0.1 (#3735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 11:02:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5825aa4c3 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:27:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ebe0c4e3c3 chore(deps): bump urllib3 from 2.5.0 to 2.6.3 (#3733)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:26:45 +02:00
Djuri BaarsandGitHub 383bdb6312 [fix] Fix missing timezone information 2026-01-20 17:26:02 +02:00
a947686d79 [feat] Update extension builder for 1.4.1 (#3725)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-20 14:47:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
28e9d32b44 chore(deps): bump werkzeug from 3.1.3 to 3.1.5 (#3732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 14:46:06 +02:00
dni ⚡andGitHub ef9adc077b chore: update to v1.4.1 (#3731) 2026-01-20 13:01:16 +01:00
Vlad StanandGitHub ffad5d79c4 fix: undelete wallet (#3730) 2026-01-20 13:51:20 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
09c35eaca0 chore(deps-dev): bump filelock from 3.20.1 to 3.20.3 (#3721)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 09:17:26 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
52adc62bc4 chore(deps): bump filelock from 3.20.0 to 3.20.3 (#3720)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 08:34:40 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0efa173084 chore(deps): bump virtualenv from 20.35.4 to 20.36.1 (#3719)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 08:34:21 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
724f8c9f63 chore(deps-dev): bump virtualenv from 20.31.2 to 20.36.1 (#3718)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 08:34:05 +01:00
Vlad StanandGitHub c6981089e5 fix: handle pre-populated data (#3717) 2026-01-13 08:05:47 +01:00
Vlad Stan 13480027dd chore: bump release candidate 2026-01-12 13:33:53 +02:00
93f1006d3a feat: wire up paidreviews (#3656)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-12 13:17:39 +02:00
Tiago VasconcelosandGitHub 5490367179 fix: search input on extension page (#3705) 2026-01-12 12:39:48 +02:00
a185197e34 Fix: Update apipayments updated_at field when a payment is updated. (#3699)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-09 09:52:10 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aa1bddcdd5 chore(deps-dev): bump werkzeug from 3.1.4 to 3.1.5 (#3714)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-09 06:26:32 +01:00
dni ⚡andGitHub 6d24ba55aa fix: /user/{user_id}/wallet should return deleted wallet (#3713) 2026-01-09 06:25:40 +01:00
Vlad StanandGitHub 5b2937672c fix: delete user with wallets (#3711) 2026-01-08 16:39:37 +01:00
Vlad StanandGitHub 957d9ce419 fix: password reset from UI (#3712) 2026-01-08 16:38:48 +01:00
PatMulliganandGitHub 0e21b77b74 fix(lndrest): use boolean for allow_self_payment instead of integer (#3700) 2026-01-08 09:54:05 +01:00
24e28911ab fix: greenlet import issue mac osx (#3698)
Co-authored-by: Pratik Patel <pratikpatelpp802@gmail.com>
2026-01-08 09:16:50 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1e252d0485 chore(deps): bump urllib3 from 2.6.0 to 2.6.3 (#3706)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 08:50:24 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e98550a125 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3703)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 08:50:10 +01:00
dni ⚡andGitHub bc55474859 fix: export qrcode was undefined (#3689) 2025-12-29 19:45:15 +01:00
dni ⚡andGitHub d985bdaadb chore: update breez liquid sdk (#3685) 2025-12-27 12:07:08 +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
226 changed files with 19151 additions and 11124 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
+8 -4
View File
@@ -6,6 +6,10 @@
# The following settings are ONLY set in your .env file.
# They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token ===
# if set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken"
# === Logging and Development ===
DEBUG=False
@@ -18,7 +22,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
@@ -56,7 +59,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, SparkWallet, SparkL2Wallet"
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
@@ -67,6 +70,7 @@ FORWARDED_ALLOW_IPS="*"
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# ID of the super user. The user ID must exist.
# SUPER_USER=""
@@ -187,7 +191,8 @@ 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"
# TODO: add Spark
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
@@ -333,4 +338,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
######################################
###### Logging and Development #######
######################################
+8 -1
View File
@@ -75,7 +75,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 }}
+13 -2
View File
@@ -55,10 +55,21 @@ jobs:
- name: Build and push boltz
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.boltz
context: docker/boltzclient
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
build-args: LNBITS_TAG=${{ inputs.tag }}
- name: Build and push sparkl2
uses: docker/build-push-action@v5
with:
context: docker/sparkl2
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-sparkl2:${{ inputs.tag }}
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 }}
+12 -11
View File
@@ -38,10 +38,9 @@ jobs:
- name: Setup Regtest
run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
cd docker/regtest
chmod +x ./start-regtest
./start-regtest
sudo chmod -R a+rwx .
- name: Run pytest
@@ -50,19 +49,21 @@ jobs:
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
LND_REST_ENDPOINT: https://localhost:8081/
LND_REST_CERT: ./docker/data/lnd-3/tls.cert
LND_REST_MACAROON: ./docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
LND_REST_CERT: ./docker/regtest/data/lnd-3/tls.cert
LND_REST_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
LND_GRPC_ENDPOINT: localhost
LND_GRPC_PORT: 10009
LND_GRPC_CERT: docker/data/lnd-3/tls.cert
LND_GRPC_MACAROON: docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
LND_GRPC_CERT: ./docker/regtest/data/lnd-3/tls.cert
LND_GRPC_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
CORELIGHTNING_RPC: ./docker/regtest/data/clightning-1/regtest/lightning-rpc
CORELIGHTNING_REST_URL: https://localhost:3001
CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
CORELIGHTNING_REST_MACAROON: ./docker/regtest/data/clightning-2-rest/access.macaroon
CORELIGHTNING_REST_CERT: ./docker/regtest/data/clightning-2-rest/certificate.pem
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
+41 -7
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,14 +41,26 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+23 -9
View File
@@ -14,7 +14,7 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -22,10 +22,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release create "$tag" --generate-notes --draft --json upload_url -q '.upload_url')
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:
@@ -35,6 +43,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
@@ -44,17 +53,22 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
-1
View File
@@ -42,7 +42,6 @@ lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old
lnbits/static/bundle-components.min.js.old
lnbits/upgrades
docker
# Nix
*result*
+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]
@@ -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
@@ -13,11 +14,13 @@ ENV PATH="/root/.local/bin:$PATH"
# Reinstall dependencies just in case (needed for CMD usage)
RUN uv sync --all-extras
# LNbits + boltzd configuration
# LNbits
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
ENV FUNDING_SOURCE_MAX_RETRIES=10
# Boltzd
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
@@ -25,8 +28,7 @@ ENV BOLTZ_CLIENT_WALLET="lnbits"
EXPOSE 5000
# Entrypoint to start boltzd and LNbits
COPY dockerboltz.sh /dockerboltz.sh
RUN chmod +x /dockerboltz.sh
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/dockerboltz.sh"]
CMD ["/entrypoint.sh"]
+18
View File
@@ -0,0 +1,18 @@
!data
data/*
!data/boltz
data/boltz/*
!data/boltz/boltz.conf
!data/boltz-client
data/boltz-client/*
!data/boltz-client/boltz.toml
!data/boltz-nginx
data/boltz-nginx/*
!data/boltz-nginx/default.conf
!data/eclair
data/eclair/*
!data/eclair/eclair.conf
+99
View File
@@ -0,0 +1,99 @@
![TESTS](https://github.com/lnbits/legend-regtest-enviroment/actions/workflows/ci.yml/badge.svg)
# nodes
- lnd-1: for locally testing your current lnbits
- lnd-2: used for boltz backend
- lnd-3: used for lnbits inside docker
- cln-1: for locally testing your current lnbits
- cln-2: used for clightning-REST
- eclair-1: for locally testing your current lnbits
# Installing regtest
get the regtest enviroment ready
```sh
# Install docker https://docs.docker.com/engine/install/
# Make sure your user has permission to use docker 'sudo usermod -aG docker ${USER}' then reboot
# Stop/start docker 'sudo systemctl stop docker' 'sudo systemctl start docker'
sudo apt install jq
git clone https://github.com/lnbits/lnbits.git
cd lnbits
docker build -t lnbits/lnbits .
mkdir docker
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./start-regtest
./start-regtest # start the regtest and also run tests
sudo chown -R $USER ./data # Give the data file permissions for user
```
# Running LNbits on regtest
add this ENV variables to your `.env` file
```sh
DEBUG=true
# LND
LNBITS_BACKEND_WALLET_CLASS="LndRestWallet"
LND_REST_ENDPOINT=https://127.0.0.1:8081/
LND_REST_CERT=/home/user/repos/lnbits/docker/data/lnd-1/tls.cert
LND_REST_MACAROON=/home/user/repos/lnbits/docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
# CLN
LNBITS_BACKEND_WALLET_CLASS="CoreLightningWallet"
CORELIGHTNING_RPC=./docker/data/clightning-1/regtest/lightning-rpc
# Run LNbits
uv run lnbits
# Run LNbits with hot reload
make dev
```
# testing
```sh
chmod +x ./start-regtest
./start-regtest
# short answer :)
./start-regtest && echo "PASSED" || echo "FAILED" > /dev/null
```
usage of the `bitcoin-cli-sim`, `lightning-cli-sim` and `lncli-sim` aliases
```sh
cd ~/lnbits/docker
source docker-scripts.sh
# use bitcoin core, mine a block
bitcoin-cli-sim -generate 1
# use c-lightning nodes
lightning-cli-sim 1 newaddr | jq -r '.bech32' # use node 1
lightning-cli-sim 2 getinfo # use node 2
lightning-cli-sim 3 getinfo # use node 3
# use lnd nodes
lncli-sim 1 newaddr p2wsh
lncli-sim 2 listpeers
```
# urls
- mempool: http://localhost:8080/
- boltz api: http://localhost:9001/
- lnd-1 rest: http://localhost:8081/
- lnbits: http://localhost:5001/
# debugging docker logs
```sh
docker logs lnbits-lnbits-1 -f
docker logs lnbits-boltz-1 -f
docker logs lnbits-clightning-1-1 -f
docker logs lnbits-lnd-2-1 -f
```
@@ -0,0 +1,49 @@
standalone = true
network = "regtest"
# Path the the log file
logfile = ""
electrumUrl = "electrs:19001"
electrumLiquidUrl = "electrs-liquid:19002"
[BOLTZ]
# By default the daemon automatically connects to the official Boltz instance for the network LND is on
# This value is used to override that
url = "http://boltz-nginx:9001"
[DATABASE]
# Path to the SQLite database file
# path = "/home/michael/test.db"
[RPC]
# Host of
host = "0.0.0.0"
# Port of the gRPC interface
port = 9002
# Whether the REST proxy for the gRPC interface should be disabled
restDisabled = false
# Host of the REST proxy
restHost = "0.0.0.0"
# Port of the REST proxy
restPort = 9003
# Path to the TLS cert for the gRPC and REST interface
tlsCert = ""
# Path to the TLS private key for the gRPC and REST interface
tlsKey = ""
noTls = true
# Whether the macaroon authentication for the gRPC and REST interface should be disabled
noMacaroons = true
# Path to the admin macaroon for the gRPC and REST interface
adminMacaroonPath = ""
# Path to the read only macaroon for the gRPC and REST interface
readOnlyMacaroonPath = ""
@@ -0,0 +1,49 @@
upstream boltz {
server boltz:9001;
}
upstream boltzr {
server boltz:9005;
}
upstream ws {
server boltz:9004;
}
server {
listen 9001;
listen [::]:9001;
server_name localhost;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods 'GET, PATCH, DELETE, POST, OPTIONS' always;
add_header Access-Control-Allow-Headers "*" always;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
if ($request_method = OPTIONS) {
return 204;
}
location /v2/ws {
proxy_pass http://ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location ~ ^/v2/(lightning|swap/rescue|swap/restore) {
proxy_pass http://boltzr;
}
location /streamswapstatus {
proxy_pass http://boltzr;
}
location / {
proxy_pass http://boltz;
}
}
+111
View File
@@ -0,0 +1,111 @@
[api]
host = "0.0.0.0"
port = 9_001
[grpc]
host = "0.0.0.0"
port = 9_000
[postgres]
host = "boltz-postgres"
port = 5432
database = "boltz"
username = "boltz"
password = "boltz"
[sidecar]
[sidecar.grpc]
host = "127.0.0.1"
port = 9003
[sidecar.ws]
host = "0.0.0.0"
port = 9004
[sidecar.api]
host = "0.0.0.0"
port = 9005
[[pairs]]
isLegacy = true
base = "BTC"
quote = "BTC"
rate = 1
fee = 0.5
swapInFee = 0.1
maxSwapAmount = 40_294_967
minSwapAmount = 50_000
[pairs.timeoutDelta]
chain = 1440
reverse = 1440
swapMinimal = 1440
swapMaximal = 2880
swapTaproot = 10080
[[pairs]]
isLegacy = true
base = "L-BTC"
quote = "BTC"
fee = 0.25
swapInFee = 0.1
rate = 1
maxSwapAmount = 40_294_967
minSwapAmount = 100
[pairs.submarineSwap]
minSwapAmount = 1_000
minBatchedAmount = 21
[pairs.chainSwap]
minSwapAmount = 25_000
[pairs.timeoutDelta]
chain = 1440
reverse = 1440
swapMinimal = 1440
swapMaximal = 2880
swapTaproot = 10080
[[currencies]]
symbol = "BTC"
network = "bitcoinRegtest"
minWalletBalance = 10_000_000
minChannelBalance = 10_000_000
maxSwapAmount = 40_294_967
minSwapAmount = 10_000
maxZeroConfAmount = 0
[currencies.chain]
# mempoolSpace = "http://mempool-web:8090/api"
host = "bitcoind"
zmqpubrawtx = "tcp://bitcoind:29000"
zmqpubrawblock = "tcp://bitcoind:29001"
port = 18_443
cookie = "/root/.bitcoin/regtest/.cookie"
wallet = "lnbits"
[currencies.lnd]
host = "lnd-2"
port = 10_009
certpath = "/data/lnd/tls.cert"
macaroonpath = "/data/lnd/data/chain/bitcoin/regtest/admin.macaroon"
[liquid]
symbol = "L-BTC"
network = "liquidRegtest"
maxSwapAmount = 40_294_967
minSwapAmount = 10_000
maxZeroConfAmount = 40_294_967
[liquid.chain]
host = "elementsd"
port = 18884
cookie = "/root/.elements/liquidregtest/.cookie"
zmqpubrawtx = "tcp://elementsd:31000"
zmqpubhashblock = "tcp://elementsd:31002"
wallet = "lnbits"
+28
View File
@@ -0,0 +1,28 @@
eclair {
chain = "regtest"
api {
binding-ip = "0.0.0.0"
enabled = true
port = 8080
password = "lnbits"
}
bitcoind {
host = "bitcoind"
rpcport = 18443
auth = "safecookie"
cookie = "/root/.bitcoin/regtest/.cookie"
zmqblock = "tcp://bitcoind:29002"
zmqtx = "tcp://bitcoind:29000"
}
channel {
max-funding-satoshis = 10000000000
}
features {
option_support_large_channel = mandatory
}
}
+434
View File
@@ -0,0 +1,434 @@
services:
lnbits:
hostname: lnbits
depends_on:
- lnd-3
image: lnbits/lnbits
restart: on-failure
user: "0:0"
environment:
LNBITS_PORT: 5001
DEBUG: true
LNBITS_ADMIN_UI: false
LNBITS_BACKEND_WALLET_CLASS: "LndRestWallet"
LNBITS_DATA_FOLDER: "./data"
LND_REST_ENDPOINT: "https://lnd-3:8081/"
LND_REST_CERT: "./lnd/tls.cert"
LND_REST_MACAROON: "./lnd/data/chain/bitcoin/regtest/admin.macaroon"
ports:
- 5001:5001
volumes:
- lnbits-data:/app/data
- ./data/lnd-3:/app/lnd:uid=1000,gid=1000
boltz:
hostname: boltz
depends_on:
- lnd-2
- boltz-postgres
restart: always
image: boltz/boltz:latest
ports:
- 9000:9000
entrypoint: "sh -c 'sleep 30; /boltz-backend/bin/boltzd'"
volumes:
- ./data/lnd-2:/data/lnd/
- ./data/boltz/:/root/.boltz/
- elements-data:/root/.elements
- bitcoin-data:/root/.bitcoin
boltz-client:
hostname: boltz-client
depends_on:
- boltz
restart: always
image: boltz/boltz-client:latest
ports:
- 9002:9002
- 9003:9003
expose:
- 9002
healthcheck:
test: ['CMD', 'boltzcli', '--host', 'boltz-client', 'getinfo']
interval: 5s
timeout: 3s
retries: 10
start_period: 0s
volumes:
- elements-data:/root/.elements
- ./data/boltz-client:/root/.boltz
boltz-backend-nginx:
hostname: boltz-nginx
restart: always
image: nginx:latest
ports:
- 9001:9001
volumes:
- nginx-data:/etc/nginx/conf.d
healthcheck:
test: ['CMD-SHELL', 'curl http://localhost:9001/version']
timeout: 1s
retries: 10
interval: 1s
start_period: 0s
boltz-postgres:
hostname: boltz-postgres
restart: always
image: postgres:14-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready --dbname boltz --username boltz"]
interval: 5s
timeout: 30s
retries: 10
start_period: 5s
environment:
- POSTGRES_DB=boltz
- POSTGRES_USER=boltz
- POSTGRES_PASSWORD=boltz
expose:
- 5432
bitcoind:
hostname: bitcoind
image: boltz/bitcoin-core:25.0
command:
- -regtest
- -fallbackfee=0.00000253
- -zmqpubrawtx=tcp://0.0.0.0:29000
- -zmqpubrawblock=tcp://0.0.0.0:29001
- -zmqpubhashblock=tcp://0.0.0.0:29002
- -txindex
- -rpcallowip=0.0.0.0/0
- -rpcbind=0.0.0.0
- -addresstype=bech32
- -changetype=bech32
- -dbcache=2048
- -rpcworkqueue=256
volumes:
- bitcoin-data:/root/.bitcoin
expose:
- 29000
- 29001
- 29002
- 18443
- 18444
healthcheck:
test:
[
"CMD",
"bitcoin-cli",
"--rpccookiefile=/root/.bitcoin/regtest/.cookie",
"-regtest",
"getblockchaininfo",
]
timeout: 1s
retries: 1
interval: 1s
start_period: 0s
clightning-1:
hostname: clightning-1
depends_on:
- bitcoind
image: boltz/c-lightning:24.11
command:
- --large-channels
- --network=regtest
- --grpc-port=9736
- --bind-addr=0.0.0.0:9735
- --bitcoin-rpcconnect=bitcoind
- --bitcoin-rpcport=18443
- --clnrest-host=0.0.0.0
- --clnrest-port=3010
expose:
- 9735
ports:
- 9736:9736
- 3010:3010
volumes:
- ./data/clightning-1:/root/.lightning/
- bitcoin-data:/root/.bitcoin
clightning-2:
hostname: clightning-2
depends_on:
- bitcoind
image: boltz/c-lightning:22.11.1
command:
- --large-channels
- --network=regtest
- --grpc-port=9737
- --bind-addr=0.0.0.0:9735
- --bitcoin-rpcconnect=bitcoind
- --bitcoin-rpcport=18443
expose:
- 9735
ports:
- 9737:9737
volumes:
- ./data/clightning-2:/root/.lightning/
- bitcoin-data:/root/.bitcoin
clightning-2-rest:
hostname: clightning-2-rest
depends_on:
- clightning-2
image: saubyk/c-lightning-rest:0.10.7
entrypoint: "sh -c 'sleep 35 && /sbin/tini -g -- ./docker-entrypoint.sh'"
ports:
- 3001:3001
expose:
- 3001
volumes:
- ./data/clightning-2:/root/.lightning/:uid=1000,gid=1000
- ./data/clightning-2-rest:/usr/src/app/certs/
- bitcoin-data:/root/.bitcoin
clightning-3:
hostname: clightning-3
depends_on:
- bitcoind
image: boltz/c-lightning:24.11
command:
- --large-channels
- --network=regtest
- --grpc-port=9738
- --bind-addr=0.0.0.0:9735
- --bitcoin-rpcconnect=bitcoind
- --bitcoin-rpcport=18443
expose:
- 9735
ports:
- 9738:9738
volumes:
- ./data/clightning-3:/root/.lightning/
- bitcoin-data:/root/.bitcoin
lnd-1:
hostname: lnd-1
depends_on:
- bitcoind
image: boltz/lnd:0.18.4-beta
restart: on-failure
command:
- --listen=lnd-1:9735
- --rpclisten=lnd-1:10009
- --restlisten=lnd-1:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-1:/root/.lnd/
- bitcoin-data:/root/.bitcoin
lnd-2:
hostname: lnd-2
depends_on:
- bitcoind
image: boltz/lnd:0.19.3-beta
restart: on-failure
command:
- --listen=lnd-2:9735
- --rpclisten=lnd-2:10009
- --restlisten=lnd-2:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-2:/root/.lnd/
- bitcoin-data:/root/.bitcoin
lnd-3:
hostname: lnd-3
depends_on:
- bitcoind
image: boltz/lnd:0.18.4-beta
restart: on-failure
command:
- --listen=lnd-3:9735
- --rpclisten=lnd-3:10009
- --restlisten=lnd-3:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
ports:
- 8081:8081
- 10009:10009
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-3:/root/.lnd/
- bitcoin-data:/root/.bitcoin
lnd-4:
hostname: lnd-4
depends_on:
- bitcoind
image: boltz/lnd:0.18.4-beta
restart: on-failure
command:
- --listen=lnd-4:9735
- --rpclisten=lnd-4:10009
- --restlisten=lnd-4:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-4:/root/.lnd/
- bitcoin-data:/root/.bitcoin
eclair:
hostname: eclair
depends_on:
- bitcoind
image: boltz/eclair:0.8.0
restart: on-failure
entrypoint: "sh -c 'JAVA_OPTS=-Xmx512m /eclair-node/bin/eclair-node.sh -Declair.datadir=/root/eclair -Declair.printToConsole'"
ports:
- 8082:8080
expose:
- 9735
- 8080
volumes:
- ./data/eclair:/root/eclair
- bitcoin-data:/root/.bitcoin
electrs:
hostname: electrs
restart: always
image: boltz/electrs:latest
entrypoint: ["electrs-bitcoin"]
command:
- --electrum-rpc-addr
- electrs:19001
- --http-addr
- electrs:3002
- --daemon-rpc-addr
- bitcoind:18443
- --network
- regtest
- --jsonrpc-import
healthcheck:
test: ["CMD-SHELL", "curl -s $(hostname):3002/blocks/tip/height"]
timeout: 1s
retries: 20
interval: 2s
start_period: 5s
ports:
- 19001:19001
- 3002:3002
volumes:
- bitcoin-data:/root/.bitcoin
elementsd:
hostname: elementsd
restart: always
image: boltz/elements:latest
expose:
- 31001
ports:
- 31000:31000
- 31002:31002
- 18884:18884
command:
- -chain=liquidregtest
- -txindex=1
- -rest=1
- -server=1
- -rpcallowip=0.0.0.0/0
- -validatepegin=0
- -initialfreecoins=2100000000000000
- -fallbackfee=0.000001
- -rpcbind=0.0.0.0
- -rpcport=18884
- -zmqpubrawtx=tcp://0.0.0.0:31000
- -zmqpubrawblock=tcp://0.0.0.0:31001
- -zmqpubhashblock=tcp://0.0.0.0:31002
- -acceptdiscountct=1
- -creatediscountct=1
volumes:
- elements-data:/root/.elements
electrs-liquid:
hostname: electrs-liquid
restart: always
image: boltz/electrs:latest
entrypoint: ["electrs-liquid"]
command:
- --electrum-rpc-addr
- electrs-liquid:19002
- --http-addr
- electrs-liquid:3003
- --daemon-rpc-addr
- elementsd:18884
- --daemon-dir
- /root/.elements
- --network
- liquidregtest
- --parent-network
- regtest
- --jsonrpc-import
healthcheck:
test: ["CMD-SHELL", "curl -s $(hostname):3003/blocks/tip/height"]
timeout: 15s
retries: 20
interval: 2s
start_period: 5s
ports:
- 19002:19002
- 3003:3003
volumes:
- elements-data:/root/.elements
volumes:
lnbits-data:
bitcoin-data:
elements-data:
nginx-data:
name: nginx-data
driver: local
driver_opts:
type: none
o: bind
device: ./data/boltz-nginx/
+322
View File
@@ -0,0 +1,322 @@
#!/bin/sh
export COMPOSE_PROJECT_NAME=lnbits
boltzcli-sim() {
docker exec -it lnbits-boltz-client-1 boltzcli "$@"
}
bitcoin-cli-sim() {
docker exec lnbits-bitcoind-1 bitcoin-cli -regtest "$@"
}
elements-cli-sim() {
docker exec lnbits-elementsd-1 elements-cli -rpcport=18884 -chain=liquidregtest "$@"
}
# args(i, cmd)
lightning-cli-sim() {
i=$1
shift # shift first argument so we can use $@
docker exec lnbits-clightning-$i-1 lightning-cli --network regtest "$@"
}
# args(i, cmd)
lncli-sim() {
i=$1
shift # shift first argument so we can use $@
docker exec lnbits-lnd-$i-1 lncli --network regtest --rpcserver=lnd-$i:10009 "$@"
}
get-eclair-pubkey() {
while true; do
pubkey=$(docker exec lnbits-eclair-1 curl http://localhost:8080/getinfo -X POST -s -u :lnbits | jq -r .nodeId 2> /dev/null)
pubkeyPrefix=$(echo $pubkey | cut -c1,2)
if [[ "$pubkeyPrefix" == "02" || "$pubkeyPrefix" == "03" ]]; then
echo $pubkey
break
fi
sleep 1
done
}
wait-for-eclair-channel() {
while true; do
state=$(docker exec lnbits-eclair-1 curl http://localhost:8080/channels -X POST -s -u :lnbits | jq -r ".[0].state")
pending=$(docker exec lnbits-eclair-1 curl -s http://localhost:8080/channels -X POST -u :lnbits| jq '. | length')
echo "eclair-1 pendingchannels: $pending, current state: $state"
if [[ "$state" == "NORMAL" ]]; then
break
fi
sleep 1
done
}
# args(i)
fund_boltz_client() {
# first address of seed: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
address="el1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f3mmz5l7uw5pqmx6xf5xy50hsn6vhkm5euwt72x878eq6zxx2z0z676mna6kdq"
echo "funding: $address on boltz-client"
elements-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
}
# args(i)
fund_clightning_node() {
address=$(lightning-cli-sim $1 newaddr | jq -r .bech32)
echo "funding: $address on clightning-node: $1"
bitcoin-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
}
# args(i)
fund_lnd_node() {
address=$(lncli-sim $1 newaddress p2wkh | jq -r .address)
echo "funding: $address on lnd-node: $1"
bitcoin-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
}
# args(i, j)
connect_clightning_node() {
pubkey=$(lightning-cli-sim $2 getinfo | jq -r '.id')
lightning-cli-sim $1 connect $pubkey@lnbits-clightning-$2-1:9735 | jq -r '.id'
}
lnbits-regtest-start(){
if ! command -v jq &> /dev/null
then
echo "jq is not installed"
exit
fi
if ! command -v docker &> /dev/null
then
echo "docker is not installed"
exit
fi
if ! command -v docker version &> /dev/null
then
echo "dockerd is not running"
exit
fi
lnbits-regtest-stop
docker compose up -d --remove-orphans
lnbits-regtest-init
}
lnbits-regtest-start-log(){
lnbits-regtest-stop
docker compose up --remove-orphans
lnbits-regtest-init
}
lnbits-regtest-stop(){
docker compose down --volumes
# clean up lightning node data
sudo rm -rf ./data/clightning-1 ./data/clightning-2 ./data/clightning-3 ./data/lnd-1 ./data/lnd-2 ./data/lnd-3 ./data/lnd-4 ./data/boltz/boltz.db ./data/eclair/regtest ./data/boltz-client/liquid-wallet ./data/boltz-client/bitcoin-wallet ./data/boltz-client/wallet ./data/boltz-client/boltz.db
# recreate lightning node data folders preventing permission errors
mkdir ./data/clightning-1 ./data/clightning-2 ./data/clightning-3 ./data/lnd-1 ./data/lnd-2 ./data/lnd-3 ./data/lnd-4
}
lnbits-regtest-restart(){
lnbits-regtest-stop
lnbits-regtest-start
}
boltz-client-init(){
for i in 0 1 2; do
fund_boltz_client
done
elements-cli-sim -generate 3 > /dev/null
}
lnbits-bitcoin-init(){
echo "init_bitcoin_wallet..."
bitcoin-cli-sim createwallet lnbits || bitcoin-cli-sim loadwallet lnbits
echo "mining 150 blocks..."
bitcoin-cli-sim -generate 150 > /dev/null
}
lnbits-elements-init(){
echo "init_elements_wallet..."
elements-cli-sim createwallet lnbits || elements-cli-sim loadwallet lnbits
echo "mining 150 blocks..."
elements-cli-sim -generate 150 > /dev/null
elements-cli-sim rescanblockchain
}
lnbits-init(){
echo "init_lnbits..."
docker exec lnbits-lnbits-1 uv run python tools/create_fake_admin.py
}
lnbits-regtest-init(){
lnbits-bitcoin-init
lnbits-elements-init
lnbits-lightning-sync
lnbits-lightning-init
boltz-client-init
lnbits-init
}
lnbits-lightning-sync(){
wait-for-clightning-sync 1
wait-for-clightning-sync 2
wait-for-clightning-sync 3
wait-for-lnd-sync 1
wait-for-lnd-sync 2
wait-for-lnd-sync 3
wait-for-lnd-sync 4
}
lnbits-lightning-init(){
# create 10 UTXOs for each node
for i in 0 1 2; do
fund_clightning_node 1
fund_clightning_node 2
fund_clightning_node 3
fund_lnd_node 1
fund_lnd_node 2
fund_lnd_node 3
fund_lnd_node 4
done
echo "mining 3 blocks..."
bitcoin-cli-sim -generate 3 > /dev/null
lnbits-lightning-sync
channel_confirms=6
channel_size=24000000 # 0.024 btc
balance_size=12000000 # 0.12 btc
balance_size_msat=12000000000 # 0.12 btc
# lnd-1 -> lnd-2
lncli-sim 1 connect $(lncli-sim 2 getinfo | jq -r '.identity_pubkey')@lnbits-lnd-2-1 > /dev/null
echo "open channel from lnd-1 to lnd-2"
lncli-sim 1 openchannel $(lncli-sim 2 getinfo | jq -r '.identity_pubkey') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> lnd-3
lncli-sim 1 connect $(lncli-sim 3 getinfo | jq -r '.identity_pubkey')@lnbits-lnd-3-1 > /dev/null
echo "open channel from lnd-1 to lnd-3"
lncli-sim 1 openchannel $(lncli-sim 3 getinfo | jq -r '.identity_pubkey') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> cln-1
lncli-sim 1 connect $(lightning-cli-sim 1 getinfo | jq -r '.id')@lnbits-clightning-1-1 > /dev/null
echo "open channel from lnd-1 to cln-1"
lncli-sim 1 openchannel $(lightning-cli-sim 1 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> cln-2
lncli-sim 1 connect $(lightning-cli-sim 2 getinfo | jq -r '.id')@lnbits-clightning-2-1 > /dev/null
echo "open channel from lnd-1 to cln-2"
lncli-sim 1 openchannel $(lightning-cli-sim 2 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> cln-3
lncli-sim 1 connect $(lightning-cli-sim 3 getinfo | jq -r '.id')@lnbits-clightning-3-1 > /dev/null
echo "open channel from lnd-1 to cln-3"
lncli-sim 1 openchannel $(lightning-cli-sim 3 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-2 -> cln-2
lncli-sim 2 connect $(lightning-cli-sim 2 getinfo | jq -r '.id')@lnbits-clightning-2-1 > /dev/null
echo "open channel from lnd-2 to cln-2"
lncli-sim 2 openchannel $(lightning-cli-sim 2 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 2
# lnd-3 -> cln-3
lncli-sim 3 connect $(lightning-cli-sim 3 getinfo | jq -r '.id')@lnbits-clightning-3-1 > /dev/null
echo "open channel from lnd-3 to cln-1"
lncli-sim 3 openchannel $(lightning-cli-sim 3 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 3
# lnd-3 -> cln-1
lncli-sim 3 connect $(lightning-cli-sim 1 getinfo | jq -r '.id')@lnbits-clightning-1-1 > /dev/null
echo "open channel from lnd-3 to cln-1"
lncli-sim 3 openchannel $(lightning-cli-sim 1 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 3
# lnd-1 -> eclair-1
lncli-sim 1 connect $(get-eclair-pubkey)@lnbits-eclair-1 > /dev/null
echo "open channel from lnd-2 to eclair-1"
lncli-sim 1 openchannel $(get-eclair-pubkey) $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-2 -> eclair-1
lncli-sim 2 connect $(get-eclair-pubkey)@lnbits-eclair-1 > /dev/null
echo "open channel from lnd-2 to eclair-1"
lncli-sim 2 openchannel $(get-eclair-pubkey) $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 2
wait-for-clightning-channel 1
wait-for-clightning-channel 2
wait-for-clightning-channel 3
wait-for-eclair-channel
lnbits-lightning-sync
}
wait-for-lnd-channel(){
while true; do
pending=$(lncli-sim $1 pendingchannels | jq -r '.pending_open_channels | length')
echo "lnd-$1 pendingchannels: $pending"
if [[ "$pending" == "0" ]]; then
break
fi
sleep 1
done
}
wait-for-lnd-sync(){
while true; do
if [[ "$(lncli-sim $1 getinfo 2>&1 | jq -r '.synced_to_chain' 2> /dev/null)" == "true" ]]; then
echo "lnd-$1 is synced!"
break
fi
echo "waiting for lnd-$1 to sync..."
sleep 1
done
}
wait-for-clightning-channel(){
while true; do
pending=$(lightning-cli-sim $1 getinfo | jq -r '.num_pending_channels | length')
echo "cln-$1 pendingchannels: $pending"
if [[ "$pending" == "0" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_bitcoind_sync' 2> /dev/null)" == "null" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_lightningd_sync' 2> /dev/null)" == "null" ]]; then
break
fi
fi
fi
sleep 1
done
}
wait-for-clightning-sync(){
while true; do
if [[ ! "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.id' 2> /dev/null)" == "null" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_bitcoind_sync' 2> /dev/null)" == "null" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_lightningd_sync' 2> /dev/null)" == "null" ]]; then
echo "cln-$1 is synced!"
break
fi
fi
fi
echo "waiting for cln-$1 to sync..."
sleep 1
done
}
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
print_success() {
printf "\033[;1;32mPASSED\033[;0m $1\n"
}
print_error() {
printf "\033[;1;31mFAILED\033[;0m $1\n"
}
run(){
label=$1
value=$2
cmd=$3
if [[ "$cmd" == "$value" ]]; then
print_success "$label is $cmd"
else
print_error "$label is $cmd, should be $value"
failed="true"
fi
}
failed="false"
blockheight=213
utxos=3
channel_size=24000000 # 0.024 btc
balance_size=12000000 # 0.012 btc
source $(pwd)/docker-scripts.sh
lnbits-regtest-start
echo "=================================="
printf "\033[;1;36mregtest started! starting tests...\033[;0m\n"
echo "=================================="
echo ""
for i in 1 2 3; do
run "lnd-$i .synced_to_chain" "true" $(lncli-sim $i getinfo | jq -r ".synced_to_chain")
run "lnd-$i utxo count" $utxos $(lncli-sim $i listunspent | jq -r ".utxos | length")
run "lnd-$i .block_height" $blockheight $(lncli-sim $i getinfo | jq -r ".block_height")
if [[ "$i" == "1" ]]; then
channel_count=6
else
channel_count=3
fi
run "lnd-$i openchannels" $channel_count $(lncli-sim $i listchannels | jq -r ".channels | length")
run "lnd-$i .channels[0].capacity" $channel_size $(lncli-sim $i listchannels | jq -r ".channels[0].capacity")
run "lnd-$i .channels[0].push_amount_sat" $balance_size $(lncli-sim $i listchannels | jq -r ".channels[0].push_amount_sat")
done
for i in 1 2 3; do
# run "cln-$i blockheight" $blockheight $(lightning-cli-sim $i getinfo | jq -r ".blockheight")
run "cln-$i utxo count" $utxos $(lightning-cli-sim $i listfunds | jq -r ".outputs | length")
run "cln-$i openchannels" 2 $(lightning-cli-sim $i getinfo | jq -r ".num_active_channels")
run "cln-$i channel[0].state" "CHANNELD_NORMAL" $(lightning-cli-sim $i listfunds | jq -r ".channels[0].state")
run "cln-$i channel[0].amount_msat" $(($channel_size * 1000)) $(lightning-cli-sim $i listfunds | jq -r ".channels[0].amount_msat" | sed 's/msat//g')
run "cln-$i channel[0].our_amount_msat" $(($balance_size * 1000)) $(lightning-cli-sim $i listfunds | jq -r ".channels[0].our_amount_msat" | sed 's/msat//g')
done
run "eclair-1 openchannels" 2 $(docker exec lnbits-eclair-1 curl -s http://localhost:8080/channels -X POST -u :lnbits| jq '. | length')
run "eclair-1 blockHeight" $blockheight $(docker exec lnbits-eclair-1 curl -s http://localhost:8080/getinfo -X POST -u :lnbits| jq '.blockHeight')
run "lnbits service status" "200" $(curl -s -o /dev/null -w "%{http_code}" "http://localhost:5001/")
run "boltz service status" "200" $(curl -s -o /dev/null --head -w "%{http_code}" "http://localhost:9001/version")
# return non-zero exit code if a test fails
if [[ "$failed" == "true" ]]; then
echo ""
echo "=================================="
print_error "one more more tests failed"
echo "=================================="
exit 1
else
echo ""
echo "=================================="
print_success "all tests passed! yay!"
echo "=================================="
fi
+36
View File
@@ -0,0 +1,36 @@
ARG LNBITS_TAG=latest
FROM lnbits/lnbits:${LNBITS_TAG}
RUN apt-get update && apt-get -y upgrade
RUN apt-get install -y netcat-openbsd npm nodejs git
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# install sparksidecar
RUN git clone https://github.com/lnbits/spark_sidecar
RUN cd spark_sidecar && npm install
# Reinstall dependencies for lnbits just in case (needed for CMD usage)
RUN uv sync --all-extras
# LNBITS
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="SparkL2Wallet"
ENV FUNDING_SOURCE_MAX_RETRIES=10
# spark sidecar
ENV SPARK_MNEMONIC="bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom"
ENV SPARK_NETWORK="MAINNET"
ENV SPARK_SIDECAR_PORT="8765"
ENV SPARK_PAY_WAIT_MS="20000"
EXPOSE 5000
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Entrypoint to start sparksidebar and LNbits
CMD ["/entrypoint.sh"]
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
set -e
node spark_sidecar/server.mjs &
SIDECAR_PID=$!
# Wait for startup
for i in {1..10}; do
if nc -z localhost $SPARK_SIDECAR_PORT; then
echo "sparksidebar is up!"
break
fi
echo "Waiting for sparksidebar to start..."
sleep 1
done
# Optional: check if still not up
if ! nc -z localhost $SPARK_SIDECAR_PORT; then
echo "sparksidebar did not start successfully."
exit 1
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
+4 -1
View File
@@ -50,6 +50,7 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
| **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| **Spark (L2)** | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Easy | Low | Minimal fees | Medium | Runs via Spark sidecar; seed-based self-custody. |
| **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
@@ -57,7 +58,9 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
---
## Spark (L2)
Spark L2 uses a local Node.js sidecar to expose an HTTP API that LNbits can use as a funding source. It is self-custodial and secured by a standard mnemonic seed. You can let LNbits run the sidecar internally or run your own instance (see `https://github.com/lnbits/spark_sidecar`).
### Notes for readers
+43 -1
View File
@@ -41,6 +41,7 @@ A backend wallet is selected and configured entirely through LNbits environment
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
| [Spark (L2)](#spark-l2) | | |
| [Cliche Wallet](#cliche-wallet) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
@@ -52,7 +53,7 @@ A backend wallet is selected and configured entirely through LNbits environment
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
**Environment variables**
@@ -127,6 +128,47 @@ Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Ligh
- `SPARK_URL`: `http://10.147.17.230:9737/rpc`
- `SPARK_TOKEN`: `secret_access_key`
## Spark (L2)
Spark L2 is a self-custodial funding source that uses a small Node.js sidecar to talk to the Spark network and expose an HTTP API. You can let LNbits run the sidecar internally, or run it yourself and point LNbits at it.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `SparkL2Wallet`
**Internal sidecar (LNbits-managed)**
- `SPARK_L2_INTERNAL_SIDECAR_VERSION`: `0.1.1`
- `SPARK_L2_MNEMONIC`: `...` (optional; auto-generated if blank)
- `SPARK_L2_NETWORK`: `MAINNET`
Optional tuning:
- `SPARK_L2_PAY_WAIT_MS`: `4000`
- `SPARK_L2_PAY_POLL_MS`: `500`
- `SPARK_L2_STREAM_KEEPALIVE_MS`: `15000`
**External sidecar**
- `SPARK_L2_EXTERNAL_ENDPOINT`: `http://127.0.0.1:8765`
- `SPARK_L2_EXTERNAL_API_KEY`: `...` (if your sidecar requires auth)
Example: run the sidecar manually (external mode)
```bash
git clone https://github.com/lnbits/spark_sidecar.git
cd spark_sidecar
npm install
SPARK_MNEMONIC="bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom" \
SPARK_NETWORK=MAINNET \
SPARK_SIDECAR_PORT=8765 \
SPARK_PAY_WAIT_MS=20000 \
node server.mjs
```
For testing, you can generate a 12-word mnemonic at `https://iancoleman.io/bip39/`. Store it securely — it controls your funds. Then select **Spark (L2)** as the funding source in LNbits.
## LND (REST)
**Required env vars**
+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",
+28 -2
View File
@@ -1,6 +1,7 @@
import asyncio
import glob
import importlib
import json
import os
import shutil
import sys
@@ -35,9 +36,12 @@ from lnbits.core.tasks import (
purge_audit_data,
run_by_the_minute_tasks,
wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages,
)
from lnbits.core.tasks import (
wait_for_paid_invoices as wait_for_paid_invoices_core,
)
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.settings import settings
@@ -419,6 +423,8 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
def register_ext_tasks(ext: Extension) -> None:
"""Register extension async tasks."""
if ext.extension_type == "wasm":
return
ext_module = importlib.import_module(ext.module_name)
if hasattr(ext_module, f"{ext.code}_start"):
@@ -428,6 +434,12 @@ def register_ext_tasks(ext: Extension) -> None:
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension."""
if ext.extension_type != "wasm":
ext.extension_type = _load_extension_type(ext.code) or ext.extension_type
if ext.extension_type == "wasm":
settings.activate_extension_paths(ext.code, ext.upgrade_hash, [])
register_wasm_ext_routes(app, ext)
return
ext_module = importlib.import_module(ext.module_name)
ext_route = getattr(ext_module, f"{ext.code}_ext")
@@ -453,6 +465,20 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix)
def _load_extension_type(ext_id: str) -> str | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type")
except Exception:
return None
async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
@@ -476,7 +502,7 @@ def register_async_tasks() -> None:
# core invoice listener
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
create_permanent_task(lambda: wait_for_paid_invoices_core(invoice_queue))
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
+4 -1
View File
@@ -1,3 +1,5 @@
import re
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
@@ -49,7 +51,8 @@ async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None
)
# Check that 'ext_id' is a valid extension id and not a malicious string
if not row:
raise Exception(f"Extension '{ext_id}' db version cannot be found")
if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", ext_id):
raise Exception(f"Extension '{ext_id}' db version cannot be found")
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
+19 -9
View File
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from time import time
from typing import Any
@@ -33,9 +34,9 @@ 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 = {
@@ -46,7 +47,7 @@ async def get_standalone_payment(
clause = f"({clause}) AND amount > 0"
if wallet_id:
wallet = await get_wallet(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
@@ -69,7 +70,7 @@ 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)
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(
@@ -124,7 +125,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,
}
@@ -134,7 +134,7 @@ async def get_payments_paginated( # noqa: C901
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
wallet = await get_wallet(wallet_id)
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return Page(data=[], total=0)
@@ -305,8 +305,16 @@ async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
{"new_id": new_checking_id, "old_id": checking_id},
f"""
UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
WHERE checking_id = :old_id
""", # noqa: S608
{
"new_id": new_checking_id,
"old_id": checking_id,
"now": int(time()),
},
)
@@ -315,6 +323,7 @@ async def update_payment(
new_checking_id: str | None = None,
conn: Connection | None = None,
) -> None:
payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
@@ -326,6 +335,7 @@ 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()
@@ -361,13 +371,13 @@ async def get_payments_history(
filters.values(values),
)
if wallet_id:
wallet = await get_wallet(wallet_id)
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
+92 -33
View File
@@ -4,10 +4,12 @@ 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 create_wallet, get_wallets
from lnbits.core.crud.wallets import clear_wallet_cache, create_wallet, get_wallets
from lnbits.core.db import db
from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page
from lnbits.helpers import sha256s
from lnbits.utils.cache import cache
from ..models import (
Account,
@@ -30,9 +32,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
@@ -41,6 +43,7 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
"DELETE from accounts WHERE id = :user",
{"user": user_id},
)
await clear_user_id_cache(user_id)
async def get_accounts(
@@ -69,6 +72,7 @@ async def get_accounts(
accounts.email,
accounts.pubkey,
accounts.external_id,
accounts.activated,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
@@ -93,12 +97,18 @@ async def get_accounts(
)
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
async def get_account(
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(user_id) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE id = :id",
{"id": user_id},
"""
SELECT * FROM accounts
WHERE id = :id AND (activated = true OR activated = :activated)
""",
{"id": user_id, "activated": active_only},
Account,
)
@@ -124,71 +134,98 @@ async def delete_accounts_no_wallets(
async def get_account_by_username(
username: str, conn: Connection | None = None
username: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(username) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(username) = :username",
{"username": username.lower()},
"""
SELECT * FROM accounts
WHERE
LOWER(username) = :username
AND (activated = true OR activated = :activated)
""",
{"username": username.lower(), "activated": active_only},
Account,
)
async def get_account_by_pubkey(
pubkey: str, conn: Connection | None = None
pubkey: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey.lower()},
"""
SELECT * FROM accounts
WHERE
LOWER(pubkey) = :pubkey
AND (activated = true OR activated = :activated)
""",
{"pubkey": pubkey.lower(), "activated": active_only},
Account,
)
async def get_account_by_email(
email: str, conn: Connection | None = None
email: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(email) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(email) = :email",
{"email": email.lower()},
"""
SELECT * FROM accounts
WHERE
LOWER(email) = :email
AND (activated = true OR activated = :activated)
""",
{"email": email.lower(), "activated": active_only},
Account,
)
async def get_account_by_username_or_email(
username_or_email: str, conn: Connection | None = None
username_or_email: str,
active_only: bool = True,
conn: Connection | None = None,
) -> Account | None:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE LOWER(email) = :value or LOWER(username) = :value
WHERE
(LOWER(email) = :value or LOWER(username) = :value)
AND (activated = true OR activated = :activated)
""",
{"value": username_or_email.lower()},
{"value": username_or_email.lower(), "activated": active_only},
Account,
)
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 def get_user(
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
account = await get_account(user_id, active_only, 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=conn)
wallets = await get_wallets(account.id, deleted=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)
if len(wallets) == 0:
wallet = await create_wallet(user_id=account.id, conn=conn)
wallets.append(wallet)
return User(
id=account.id,
activated=account.activated,
email=account.email,
username=account.username,
pubkey=account.pubkey,
@@ -202,21 +239,43 @@ async def get_user_from_account(
super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None,
ui_customization=account.ui_customization or {},
)
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(
user_id: str, conn: Connection | None = None
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> UserAcls:
user_acls = await (conn or db).fetchone(
"SELECT id, access_control_list FROM accounts WHERE id = :id",
{"id": user_id},
"""
SELECT id, access_control_list FROM accounts
WHERE id = :user_id AND (activated = true OR activated = :activated)
""",
{"user_id": user_id, "activated": active_only},
UserAcls,
)
return user_acls or UserAcls(id=user_id)
async def clear_user_id_cache(user_id: str):
user = await get_user(user_id, active_only=True)
if user:
clear_user_cache(user)
def clear_user_cache(user: User):
user_cache_key: str | None = cache.pop(
f"auth:user:cache_key:{sha256s(user.id)}", None
)
if user_cache_key:
cache.pop(user_cache_key)
for wallet in user.wallets:
clear_wallet_cache(wallet)
+45 -4
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, WalletType
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
@@ -44,13 +45,14 @@ async def update_wallet(
async def delete_wallet(
*,
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Connection | None = None,
) -> None:
clear_wallet_id_cache(wallet_id)
now = int(time())
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
@@ -63,6 +65,7 @@ async def delete_wallet(
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
clear_wallet_id_cache(wallet_id)
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
@@ -72,6 +75,7 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None
) -> int | None:
clear_wallet_id_cache(wallet_id)
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -222,11 +226,14 @@ async def get_wallet_for_key(
) -> Wallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT *, COALESCE((
SELECT wallets.*, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)
AS balance_msat FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""",
{"key": key},
Wallet,
@@ -240,6 +247,27 @@ async def get_wallet_for_key(
return wallet
async def get_base_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> BaseWallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT wallets.id, "user", wallet_type, adminkey, inkey FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""",
{"key": key},
BaseWallet,
)
if not wallet:
return None
return wallet
async def get_source_wallet(
wallet: Wallet, conn: Connection | None = None
) -> Wallet | None:
@@ -270,3 +298,16 @@ async def get_total_balance(conn: Connection | None = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first()
return row.get("balance", 0) or 0
def clear_wallet_id_cache(wallet_id: str):
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}")
def clear_wallet_cache(wallet: Wallet):
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}")
+30
View File
@@ -22,6 +22,8 @@ from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None
):
if _is_wasm_extension(ext):
return
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -58,6 +60,34 @@ async def run_migration(
await update_migration_version(conn, db_name, version)
def _is_wasm_extension(ext: InstallableExtension) -> bool:
if ext.meta and ext.meta.extension_type == "wasm":
return True
try:
import json
from pathlib import Path
candidate_dirs = [
Path(ext.ext_dir),
Path(settings.lnbits_extensions_path, "extensions", ext.id),
Path(settings.lnbits_path, "extensions", ext.id),
Path.cwd() / "extensions" / ext.id,
]
for base in candidate_dirs:
conf_path = Path(base, "config.json")
if not conf_path.is_file():
continue
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
if config_json.get("extension_type") == "wasm":
return True
except Exception as exc:
logger.debug(f"Failed to load extension config for '{ext.id}': {exc!s}")
return False
def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits")
+78
View File
@@ -786,3 +786,81 @@ async def m038_add_labels_for_payments(db: Connection):
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}");
"""
)
async def m043_add_ui_customization_to_accounts(db: Connection):
"""
Adds ui_customization column to accounts.
Used for server side persistence of UI customization settings.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
async def m044_add_activated_to_accounts(db: Connection):
"""
Adds activated column to accounts.
Used for account activation status.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
+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",
]
+139 -3
View File
@@ -6,12 +6,13 @@ import json
import os
import shutil
import zipfile
from asyncio.tasks import create_task
from pathlib import Path
from typing import Any
import httpx
from loguru import logger
from pydantic import BaseModel
from pydantic import BaseModel, Field
from lnbits.helpers import (
download_url,
@@ -20,6 +21,7 @@ from lnbits.helpers import (
version_parse,
)
from lnbits.settings import settings
from lnbits.utils.cache import cache
class ExplicitRelease(BaseModel):
@@ -81,6 +83,11 @@ class ExtensionConfig(BaseModel):
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
permissions: list[ExtensionPermission] = []
extension_type: str | None = "python"
public_kv_keys: list[str] = []
public_wasm_functions: list[str] = []
payment_tags: list[str] = []
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -113,6 +120,20 @@ class PayToEnableInfo(BaseModel):
class UserExtensionInfo(BaseModel):
paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None
granted_permissions: list[str] | None = None
granted_payment_tags: list[str] | None = None
class ExtensionPermission(BaseModel):
id: str
label: str
description: str
dangerous: bool | None = False
class ExtensionPermissionsGrant(BaseModel):
permissions: list[str] = []
payment_tags: list[str] = []
class UserExtension(BaseModel):
@@ -145,6 +166,10 @@ class Extension(BaseModel):
short_description: str | None = None
tile: str | None = None
upgrade_hash: str | None = ""
extension_type: str | None = None
public_kv_keys: list[str] = []
public_wasm_functions: list[str] = []
payment_tags: list[str] = []
@property
def module_name(self) -> str:
@@ -168,6 +193,12 @@ class Extension(BaseModel):
short_description=ext_info.short_description,
tile=ext_info.icon,
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
extension_type=ext_info.meta.extension_type if ext_info.meta else None,
public_kv_keys=ext_info.meta.public_kv_keys if ext_info.meta else [],
public_wasm_functions=(
ext_info.meta.public_wasm_functions if ext_info.meta else []
),
payment_tags=ext_info.meta.payment_tags if ext_info.meta else [],
)
@@ -267,10 +298,30 @@ class ExtensionRelease(BaseModel):
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try:
github_releases = await cls.fetch_github_releases(org, repo)
return [
extension_releases = [
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases
]
for release in extension_releases:
if not release.details_link:
continue
try:
config = await ExtensionConfig.fetch_github_release_config(
org, repo, release.version
)
except Exception as e:
logger.warning(e)
config = None
if not config:
continue
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
return extension_releases
except Exception as e:
logger.warning(e)
return []
@@ -309,6 +360,11 @@ class ExtensionMeta(BaseModel):
pay_to_enable: PayToEnableInfo | None = None
payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = []
permissions: list[ExtensionPermission] = []
extension_type: str | None = "python"
public_kv_keys: list[str] = []
public_wasm_functions: list[str] = []
payment_tags: list[str] = []
archive: str | None = None
featured: bool = False
paid_features: str | None = None
@@ -432,6 +488,14 @@ class InstallableExtension(BaseModel):
self.name = config_json.get("name")
self.short_description = config_json.get("short_description")
if self.meta:
self.meta.permissions = config_json.get("permissions", [])
self.meta.extension_type = config_json.get("extension_type", "python")
self.meta.public_kv_keys = config_json.get("public_kv_keys", [])
self.meta.public_wasm_functions = config_json.get(
"public_wasm_functions", []
)
self.meta.payment_tags = config_json.get("payment_tags", [])
if (
self.meta
@@ -550,6 +614,11 @@ class InstallableExtension(BaseModel):
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
permissions=config.permissions,
extension_type=config.extension_type,
public_kv_keys=config.public_kv_keys,
public_wasm_functions=config.public_wasm_functions,
payment_tags=config.payment_tags,
),
)
except Exception as e:
@@ -595,7 +664,14 @@ class InstallableExtension(BaseModel):
source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"),
max_lnbits_version=config_json.get("max_lnbits_version"),
)
),
permissions=config_json.get("permissions", []),
extension_type=config_json.get("extension_type", "python"),
public_kv_keys=config_json.get("public_kv_keys", []),
public_wasm_functions=config_json.get(
"public_wasm_functions", []
),
payment_tags=config_json.get("payment_tags", []),
),
)
@@ -606,6 +682,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] = []
@@ -744,6 +851,32 @@ class ExtensionDetailsRequest(BaseModel):
version: str
class ExtensionReviewsStatus(BaseModel):
tag: str
avg_rating: float
review_count: int
class CreateExtensionReview(BaseModel):
tag: str
name: str | None = Field(None)
rating: int = Field(..., ge=0, le=1000)
comment: str | None = Field(None)
class ExtensionReviewPaymentRequest(BaseModel):
payment_hash: str
payment_request: str
class ExtensionReview(BaseModel):
id: str
name: str | None = Field(default=None)
tag: str | None = Field(default=None)
rating: int = Field(default=0, ge=0, le=1000)
comment: str | None = Field(default=None)
async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
@@ -762,3 +895,6 @@ def icon_to_github_url(source_repo: str, path: str | None) -> str:
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
ExtensionConfig.update_forward_refs(ExtensionPermission=ExtensionPermission)
+25
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
import random
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
@@ -55,6 +57,29 @@ class DataField(BaseModel):
field_type += ' = "sat"'
return f"{field_name}: {field_type}"
def field_to_random_value(self) -> str:
field_name = camel_to_snake(self.name)
field_value: Any = f'"{self.type}"'
if self.type == "json":
field_value = '"{}"'
elif self.type == "wallet":
field_value = f'"{uuid.uuid4()}"'
elif self.type == "currency":
field_value = '"sat"'
elif self.type in ["str", "text"]:
field_value = f'"{field_name}_{urlsafe_short_hash()}"'
elif self.type == "int":
field_value = random.randint(1, 100) # noqa: S311
elif self.type == "float":
field_value = random.uniform(1.0, 100.0) # noqa: S311
elif self.type == "bool":
field_value = random.choice([True, False]) # noqa: S311
elif self.type == "datetime":
random_days = random.randint(-30, 30) # noqa: S311
random_date = datetime.now(timezone.utc) - timedelta(days=random_days)
field_value = f"""datetime.fromisoformat("{random_date.isoformat()}")"""
return f"{field_name} = {field_value},"
def field_to_js(self) -> str:
field_name = camel_to_snake(self.name)
default_value = "null"
+19 -54
View File
@@ -6,23 +6,16 @@ from typing import Literal
from fastapi import Query
from lnurl import LnurlWithdrawResponse
from loguru import logger
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
@@ -130,59 +123,23 @@ class Payment(BaseModel):
"fiat_"
)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status(
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
if self.is_in and self.fiat_provider:
fiat_status = await self.check_fiat_status(
skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
logger.warning("payment.check_status() is deprecated.")
from lnbits.core.services.payments import check_payment_status
return await check_payment_status(self, skip_internal_payment_notifications)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not self.is_internal:
return FiatPaymentPendingStatus()
if self.success:
return FiatPaymentSuccessStatus()
if self.failed:
return FiatPaymentFailedStatus()
logger.warning("payment.check_fiat_status() is deprecated.")
from lnbits.core.services.fiat_providers import check_fiat_status
if not self.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = self.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(self.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(self.checking_id)
return fiat_status
return await check_fiat_status(self, skip_internal_payment_notifications)
class PaymentFilters(FilterModel):
@@ -196,7 +153,15 @@ class PaymentFilters(FilterModel):
"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
+19 -1
View File
@@ -172,14 +172,23 @@ 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):
activated: bool = True
external_id: str | None = None # for external account linking
username: str | None = None
password_hash: str | None = None
pubkey: str | None = None
email: str | None = None
extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -194,6 +203,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()
@@ -229,6 +242,7 @@ class Account(BaseModel):
class AccountOverview(Account):
activated: bool = True
transaction_count: int | None = 0
wallet_count: int | None = 0
balance_msat: int | None = 0
@@ -264,6 +278,7 @@ class AccountFilters(FilterModel):
class User(BaseModel):
id: str
activated: bool = True
created_at: datetime
updated_at: datetime
email: str | None = None
@@ -277,6 +292,7 @@ class User(BaseModel):
fiat_providers: list[str] = []
has_password: bool = False
extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
@property
def wallet_ids(self) -> list[str]:
@@ -302,6 +318,7 @@ class RegisterUser(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
invitation_code: str | None = Query(default=None, max_length=256)
class CreateUser(BaseModel):
@@ -345,6 +362,7 @@ class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
first_install_token: str | None = Query(None)
class LoginUsr(BaseModel):
+13 -4
View File
@@ -11,7 +11,7 @@ from lnbits.db import FilterModel
from lnbits.settings import settings
class BaseWallet(BaseModel):
class WalletInfo(BaseModel):
id: str
name: str
adminkey: str
@@ -110,13 +110,16 @@ class WalletExtra(BaseModel):
]
class Wallet(BaseModel):
class BaseWallet(BaseModel):
id: str
user: str
name: str
wallet_type: str = WalletType.LIGHTNING.value
adminkey: str
inkey: str
wallet_type: str = WalletType.LIGHTNING.value
class Wallet(BaseWallet):
name: str
# Must be set only for shared wallets
shared_wallet_id: str | None = None
deleted: bool = False
@@ -230,6 +233,12 @@ class WalletTypeInfo:
wallet: Wallet
@dataclass
class BaseWalletTypeInfo:
key_type: KeyType
wallet: BaseWallet
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
+4
View File
@@ -1,3 +1,4 @@
from .fiat_providers import check_fiat_status
from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
@@ -7,6 +8,7 @@ from .notifications import enqueue_admin_notification, send_payment_notification
from .payments import (
calculate_fiat_amounts,
cancel_hold_invoice,
check_payment_status,
check_transaction_status,
check_wallet_limits,
create_fiat_invoice,
@@ -40,6 +42,8 @@ __all__ = [
"calculate_fiat_amounts",
"cancel_hold_invoice",
"check_admin_settings",
"check_fiat_status",
"check_payment_status",
"check_transaction_status",
"check_wallet_limits",
"check_webpush_settings",
+27 -13
View File
@@ -3,6 +3,7 @@ import io
from uuid import uuid4
from fastapi import UploadFile
from loguru import logger
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
@@ -16,7 +17,7 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if user_id not in settings.lnbits_assets_no_limit_users:
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(
@@ -29,17 +30,7 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
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)
thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset(
id=uuid4().hex,
@@ -48,9 +39,32 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
if thumb_buffer
else None
),
data=contents,
)
await create_asset(asset)
return asset
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try:
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)
return thumb_buffer
except Exception as exc:
logger.warning(f"Failed to create thumbnail: {exc}")
return None
+37 -2
View File
@@ -10,12 +10,18 @@ from lnbits.core.crud import (
get_db_version,
get_installed_extension,
update_installed_extension_state,
update_migration_version,
)
from lnbits.core.crud.extensions import (
get_installed_extensions,
update_installed_extension,
)
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.wasm.extension_host import (
clear_schedules_for_extension,
clear_tag_watches_for_extension,
)
from lnbits.db import COCKROACH, POSTGRES, Connection, Database
from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
@@ -52,6 +58,9 @@ async def install_extension(
else:
await update_installed_extension(ext_info)
if ext_info.meta and ext_info.meta.extension_type == "wasm":
await update_migration_version(None, ext_info.id, 0)
extension = Extension.from_installable_ext(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
@@ -69,10 +78,26 @@ async def uninstall_extension(ext_id: str):
extension = await get_installed_extension(ext_id)
if extension:
if extension.meta and extension.meta.extension_type == "wasm":
await clear_tag_watches_for_extension(ext_id)
await clear_schedules_for_extension(ext_id)
await _purge_wasm_extension_db(ext_id)
extension.clean_extension_files()
await delete_installed_extension(ext_id=ext_id)
async def _purge_wasm_extension_db(ext_id: str) -> None:
cleaned = await Database.clean_ext_db_files(ext_id)
if cleaned:
return
try:
db = Database(f"ext_{ext_id}")
if db.type in {POSTGRES, COCKROACH}:
await db.execute(f"DROP SCHEMA IF EXISTS {ext_id} CASCADE")
except Exception as exc:
logger.warning(f"Failed to drop WASM extension schema for '{ext_id}': {exc}")
async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
@@ -82,6 +107,10 @@ async def activate_extension(ext: Extension):
async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
extension = await get_installed_extension(ext_id)
if extension and extension.meta and extension.meta.extension_type == "wasm":
await clear_tag_watches_for_extension(ext_id)
await clear_schedules_for_extension(ext_id)
await stop_extension_background_work(ext_id)
@@ -90,6 +119,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension must expose a `myextension_stop()` function if it is starting tasks.
"""
installed = await get_installed_extension(ext_id)
if installed and installed.meta and installed.meta.extension_type == "wasm":
return True
upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
@@ -122,6 +154,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
Extension CAN expose a `myextension_start()` function if it is starting tasks.
Extension MUST expose a `myextension_stop()` in that case.
"""
installed = await get_installed_extension(ext_id)
if installed and installed.meta and installed.meta.extension_type == "wasm":
return True
upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
@@ -149,9 +184,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:
+44 -11
View File
@@ -20,6 +20,7 @@ from lnbits.helpers import (
camel_to_words,
download_url,
lowercase_first_letter,
snake_to_camel,
)
from lnbits.settings import settings
@@ -195,13 +196,29 @@ def _copy_ext_stub_to_build_dir(
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
shutil.rmtree(ext_build_dir, True)
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
shutil.copytree(
ext_stub_cache_dir,
ext_build_dir,
ignore=shutil.ignore_patterns(
"__pycache__",
".git",
".env",
".venv",
"*.env",
"*.log",
"node_modules",
".mypy_cache",
".pytest_cache",
".ruff_cache",
),
)
return ext_build_dir
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
parsed_data = _parse_extension_data(data)
for py_file in py_files:
test_files = [f"tests/{p.name}" for p in Path(ext_stub_dir, "tests").glob("*.py")]
for py_file in py_files + test_files:
template_path = Path(ext_stub_dir, py_file).as_posix()
rederer = _render_file(template_path, parsed_data)
with open(template_path, "w", encoding="utf-8") as f:
@@ -209,7 +226,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
_remove_lines_with_string(template_path, remove_line_marker)
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
template_path = Path(ext_stub_dir, "static", "index.js").as_posix()
rederer = _render_file(
template_path, {"preview": data.preview_action, **parsed_data}
)
@@ -234,9 +251,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"settingsFormDialog.data",
ext_stub_dir,
)
template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
).as_posix()
template_path = Path(ext_stub_dir, "static", "index.vue").as_posix()
rederer = _render_file(
template_path,
{
@@ -263,12 +278,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"publicClientData",
ext_stub_dir,
)
public_template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
)
public_template_path = Path(ext_stub_dir, "static", "public_page.vue")
public_component_path = Path(ext_stub_dir, "static", "public_page.js")
template_path = public_template_path.as_posix()
if not data.public_page.has_public_page:
public_template_path.unlink(missing_ok=True)
public_component_path.unlink(missing_ok=True)
else:
rederer = _render_file(
template_path,
@@ -308,8 +323,10 @@ def zip_directory(source_dir, zip_path):
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
extension_dir_path = extension_dir.as_posix()
# the order of fields is important, do not chage
rename_values = {
"extension_builder_stub_name": data.name,
"extension_builder_stub_camel_name": snake_to_camel(data.id, True),
"extension_builder_stub_short_description": data.short_description or "",
"extension_builder_stub": data.id,
"OwnerData": data.owner_data.name,
@@ -328,7 +345,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
directory=extension_dir_path,
old_text=old_text,
new_text=new_text,
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
file_extensions=[".py", ".js", ".vue", ".html", ".md", ".json", ".toml"],
)
_rename_files_and_dirs_in_directory(
@@ -406,6 +423,7 @@ def _parse_extension_data(data: ExtensionData) -> dict:
"owner_data": {
"name": data.owner_data.name,
"editable": data.owner_data.editable,
"fields": [field.name for field in data.owner_data.fields],
"js_fields": [
field.field_to_js()
for field in data.owner_data.fields
@@ -432,6 +450,18 @@ def _parse_extension_data(data: ExtensionData) -> dict:
],
"db_fields": [field.field_to_db() for field in data.owner_data.fields],
"all_fields": [field.field_to_py() for field in data.owner_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.owner_data.fields
],
"public_fields": [
field.field_to_py()
for field in data.owner_data.fields
if field.name
in [
data.public_page.owner_data_fields.name,
data.public_page.owner_data_fields.description,
]
],
},
"client_data": {
"name": data.client_data.name,
@@ -457,6 +487,9 @@ def _parse_extension_data(data: ExtensionData) -> dict:
],
"db_fields": [field.field_to_db() for field in data.client_data.fields],
"all_fields": [field.field_to_py() for field in data.client_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.client_data.fields
],
},
"settings_data": {
"enabled": data.settings_data.enabled,
@@ -536,7 +569,7 @@ def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
def _is_excluded_dir(path):
def _is_excluded_dir(path: str) -> bool:
for excluded_dir in excluded_dirs:
if path.startswith(excluded_dir):
return True
+99
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
@@ -10,6 +12,12 @@ from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.settings import settings
@@ -27,6 +35,40 @@ async def handle_fiat_payment_confirmation(
logger.warning(e)
async def check_fiat_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not payment.is_internal:
return FiatPaymentPendingStatus()
if payment.success:
return FiatPaymentSuccessStatus()
if payment.failed:
return FiatPaymentFailedStatus()
if not payment.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = payment.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(payment.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
return fiat_status
def check_stripe_signature(
payload: bytes,
sig_header: str | None,
@@ -70,6 +112,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.")
raise ValueError("PayPal webhook cannot be verified. Missing webhook ID.")
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.")
raise ValueError("PayPal webhook cannot be verified. Missing headers.")
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.
+13
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
@@ -286,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())
@@ -305,6 +313,11 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
payment.payment_hash,
json.dumps({"pending": payment.pending, "status": payment.status}),
)
if payment.tag and not payment.is_out:
await websocket_manager.send(
f"tag:{wallet.id}:{payment.tag}",
payment.json(),
)
async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
+72 -26
View File
@@ -25,6 +25,7 @@ from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount
from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
@@ -47,7 +48,8 @@ from ..models import (
PaymentState,
Wallet,
)
from .notifications import send_payment_notification
from .fiat_providers import check_fiat_status
from .notifications import send_payment_notification_in_background
payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, asyncio.Lock] = {}
@@ -67,12 +69,14 @@ async def pay_invoice(
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.")
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
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.",
@@ -95,10 +99,12 @@ async def pay_invoice(
labels=labels,
)
payment = await _pay_invoice(wallet.source_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
@@ -197,20 +203,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,
labels=data.labels,
)
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:
@@ -355,13 +363,15 @@ async def update_pending_payments(wallet_id: str):
await update_pending_payment(payment)
async def update_pending_payment(payment: Payment) -> Payment:
status = await payment.check_status()
async def update_pending_payment(
payment: Payment, conn: Connection | None = None
) -> Payment:
status = await check_payment_status(payment)
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
@@ -610,7 +620,29 @@ async def check_transaction_status(
if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee)
return await payment.check_status()
return await check_payment_status(payment)
async def check_payment_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if payment.is_internal:
if payment.success:
return PaymentSuccessStatus()
if payment.failed:
return PaymentFailedStatus()
if payment.is_in and payment.fiat_provider:
fiat_status = await check_fiat_status(
payment, skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if payment.is_out:
status = await funding_source.get_payment_status(payment.checking_id)
else:
status = await funding_source.get_invoice_status(payment.checking_id)
return status
async def get_payments_daily_stats(
@@ -698,6 +730,7 @@ async def _pay_internal_invoice(
internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn
)
if not internal_payment:
return None
@@ -706,6 +739,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")
@@ -727,6 +761,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,
@@ -741,7 +776,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)
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
@@ -814,7 +849,8 @@ async def _pay_external_invoice(
payment = await update_payment_success_status(
payment, payment_response, conn=conn
)
await send_payment_notification(wallet, payment)
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id
@@ -857,7 +893,7 @@ async def _verify_external_payment(
raise PaymentError("Payment already paid.", status="success")
# payment failed
status = await payment.check_status()
status = await check_payment_status(payment)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
@@ -1022,3 +1058,13 @@ async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
await update_payment(payment)
return response
async def _send_payment_notification_in_background(
wallet_id: str, payment: Payment, conn: Connection | None = None
):
# fetch balance again
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment)
+56 -24
View File
@@ -3,7 +3,11 @@ from uuid import uuid4
from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.db import db
from lnbits.core.models.extensions import UserExtension
from lnbits.core.models.users import RegisterUser
from lnbits.db import Connection
from lnbits.settings import (
EditableSettings,
SuperSettings,
@@ -48,37 +52,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.")
@@ -184,3 +194,25 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
async def check_register_activation_settings(data: RegisterUser):
if not settings.lnbits_require_user_activation:
return None
if settings.lnbits_user_activation_by_invitation_code:
code = data.invitation_code.strip() if data.invitation_code else ""
if len(code) == 0:
raise ValueError("Invitation code cannot be empty.")
if code == settings.lnbits_register_reusable_activation_code:
return None
if code in settings.lnbits_register_one_time_activation_codes:
settings.lnbits_register_one_time_activation_codes.remove(code)
await set_settings_field(
"lnbits_register_one_time_activation_codes",
settings.lnbits_register_one_time_activation_codes,
)
return None
raise ValueError("Invalid invitation code.")
raise ValueError("No activation method provided.")
+3 -1
View File
@@ -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)
+14 -10
View File
@@ -8,8 +8,8 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Depends, File
from fastapi.responses import FileResponse
from lnbits.core.models import User
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,
@@ -67,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
@@ -77,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())
@@ -94,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(
@@ -110,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()
+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
+19 -19
View File
@@ -15,11 +15,11 @@ from lnbits.core.crud.assets import (
)
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import User
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_user_exists,
check_account_id_exists,
optional_user_id,
parse_filters,
)
@@ -35,10 +35,10 @@ upload_file_param = File(...)
summary="Get paginated list user assets",
)
async def api_get_user_assets(
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
filters: Filters = Depends(parse_filters(AssetFilters)),
) -> Page[AssetInfo]:
return await get_user_assets(user.id, filters=filters)
return await get_user_assets(account_id.id, filters=filters)
@asset_router.get(
@@ -48,20 +48,20 @@ async def api_get_user_assets(
)
async def api_get_asset(
asset_id: str,
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
) -> AssetInfo:
asset_info = await get_user_asset_info(user.id, asset_id)
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",
"/{asset_id}/data",
name="Get user asset data",
summary="Get user asset data data by ID",
)
async def api_get_asset_binary(
async def api_get_asset_data(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
@@ -120,12 +120,12 @@ async def api_get_asset_thumbnail(
async def api_update_asset(
asset_id: str,
data: AssetUpdate,
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
) -> AssetInfo:
if user.admin:
if account_id.is_admin_id:
asset_info = await get_asset_info(asset_id)
else:
asset_info = await get_user_asset_info(user.id, asset_id)
asset_info = await get_user_asset_info(account_id.id, asset_id)
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
@@ -144,13 +144,13 @@ async def api_update_asset(
summary="Upload user assets",
)
async def api_upload_asset(
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
file: UploadFile = upload_file_param,
public_asset: bool = False,
) -> AssetInfo:
asset = await create_user_asset(user.id, file, public_asset)
asset = await create_user_asset(account_id.id, file, public_asset)
asset_info = await get_user_asset_info(user.id, asset.id)
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.")
@@ -164,11 +164,11 @@ async def api_upload_asset(
)
async def api_delete_asset(
asset_id: str,
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
) -> SimpleStatus:
asset = await get_user_asset(user.id, asset_id)
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(user.id, asset_id)
await delete_user_asset(account_id.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.")
+127 -45
View File
@@ -4,9 +4,10 @@ import json
from collections.abc import Callable
from http import HTTPStatus
from time import time
from typing import Annotated
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
@@ -25,8 +26,16 @@ from lnbits.core.models.users import (
UpdateAccessControlList,
)
from lnbits.core.services import create_user_account
from lnbits.core.services.users import update_user_account
from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.core.services.users import (
check_register_activation_settings,
update_user_account,
)
from lnbits.decorators import (
access_token_payload,
check_account_exists,
check_admin,
check_user_exists,
)
from lnbits.helpers import (
create_access_token,
decrypt_internal_message,
@@ -80,6 +89,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(account.username, account.id, account.email)
@@ -88,7 +98,7 @@ async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"])
account = await get_account_by_pubkey(event["pubkey"], active_only=False)
if not account:
account = Account(
id=uuid4().hex,
@@ -96,6 +106,8 @@ async def nostr_login(request: Request) -> JSONResponse:
extra=UserExtra(provider="nostr"),
)
await create_user_account(account)
if not account.activated:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User is not activated.")
return _auth_success_response(account.username or "", account.id, account.email)
@@ -116,14 +128,71 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
return _auth_success_response(account.username, account.id, account.email)
@auth_router.post("/impersonate", description="Login via the User ID of another user")
async def impersonate_user(
data: LoginUsr,
user: User = Depends(check_admin),
cookie_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not cookie_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Only cookie based impersonation is allowed."
)
if data.usr == user.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "You cannot impersonate yourself.")
if settings.is_admin_user(data.usr):
# this check includes the superuser
raise HTTPException(
HTTPStatus.FORBIDDEN, "You cannot impersonate another admin user."
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
response = _auth_success_response(account.username, account.id, account.email)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
return response
@auth_router.delete(
"/impersonate", description="Stop impersonation and go back to admin"
)
async def stop_impersonate_user(
user: User = Depends(check_user_exists),
admin_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not admin_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"No admin access token found to stop impersonation.",
)
response = JSONResponse(
{"access_token": admin_access_token, "token_type": "bearer"}
)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
)
response.delete_cookie("admin_access_token")
response.delete_cookie("is_access_token_expired")
response.delete_cookie("is_lnbits_user_impersonated")
return response
@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:
@@ -136,7 +205,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")
@@ -144,13 +213,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)
@@ -175,33 +244,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.")
@@ -218,18 +284,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.")
@@ -299,12 +363,14 @@ async def register(data: RegisterUser) -> JSONResponse:
if not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if await get_account_by_username(data.username):
if await get_account_by_username(data.username, active_only=False):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
await check_register_activation_settings(data)
account = Account(
id=uuid4().hex,
email=data.email,
@@ -318,24 +384,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)
@@ -344,23 +406,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:
@@ -417,17 +475,13 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
return _auth_success_response(account.username, user_id, account.email)
@auth_router.put("/update")
@auth_router.patch("")
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.")
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:
@@ -437,13 +491,41 @@ async def update(
return await get_user_from_account(account)
@auth_router.patch("/ui")
async def update_ui_customization(
req: Request, account: Account = Depends(check_account_exists)
) -> Account:
ui_customization = await req.json()
account.ui_customization = {**(account.ui_customization or {}), **ui_customization}
if len(account.ui_customization or {}) > 1000 * 1024:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "UI customization too large. Drop some fields."
)
await update_user_account(account)
return account
@auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
if settings.first_install_token:
if not data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing first_install_token.")
if settings.first_install_token != data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid first_install_token.")
account = await get_account_by_username(data.username, False)
if account:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(settings.super_user)
if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
account.username = data.username
account.extra = account.extra or UserExtra()
account.extra.provider = "lnbits"
@@ -459,7 +541,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet"
account = await get_account_by_email(email)
account = await get_account_by_email(email, active_only=False)
if verified_user_id:
if account:
+144 -7
View File
@@ -9,7 +9,9 @@ from lnbits.core.crud.payments import (
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_stripe_signature,
verify_paypal_webhook,
)
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
@@ -22,7 +24,7 @@ callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
async def api_generic_webhook_handler(
provider_name: str, request: Request
) -> SimpleStatus:
logger.info(f"Received callback from provider: '{provider_name}'.")
if provider_name.lower() == "stripe":
payload = await request.body()
sig_header = request.headers.get("Stripe-Signature")
@@ -37,6 +39,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 +61,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 +71,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 check_fiat_status(payment)
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:
@@ -76,7 +111,7 @@ async def _handle_stripe_checkout_session_completed(event: dict):
payment = await get_standalone_payment(payment_hash)
if not payment:
raise ValueError(f"No payment found for hash: '{payment_hash}'.")
await payment.check_fiat_status()
await check_fiat_status(payment)
async def _handle_stripe_subscription_invoice_paid(event: dict):
@@ -121,7 +156,7 @@ async def _handle_stripe_subscription_invoice_paid(event: dict):
),
)
await payment.check_fiat_status()
await check_fiat_status(payment)
async def _get_stripe_subscription_payment_options(
@@ -132,7 +167,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 +178,105 @@ async def _get_stripe_subscription_payment_options(
metadata["extra"] = {}
return FiatSubscriptionPaymentOptions(**metadata)
async def handle_paypal_event(event: dict):
event_id = event.get("id", "")
event_type = event.get("event_type", "")
resource = event.get("resource", {})
logger.info(f"Handling PayPal event: '{event_id}'. Type: '{event_type}'.")
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 check_fiat_status(payment)
return
if event_type in ("PAYMENT.SALE.COMPLETED"):
await _handle_paypal_subscription_payment(resource)
return
logger.warning(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") or "").upper()
total = amount_info.get("total")
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.")
payment_options = _deserialize_paypal_metadata(custom_id)
if not payment_options.wallet_id:
raise ValueError("PayPal subscription event missing wallet_id.")
memo = payment_options.memo or ""
extra = {
**(payment_options.extra or {}),
"subscription_request_id": resource.get("billing_agreement_id"),
"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 check_fiat_status(payment)
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
def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
try:
meta = json.loads(custom_id)
wallet_id = meta[0] if len(meta) > 0 else None
tag = meta[1] if len(meta) > 1 else None
subscription_request_id = meta[2] if len(meta) > 2 else None
extra_link = meta[3] if len(meta) > 3 else None
memo = meta[4] if len(meta) > 4 else None
extra = {
"link": extra_link,
"subscription_request_id": subscription_request_id,
}
return FiatSubscriptionPaymentOptions(
wallet_id=wallet_id,
tag=tag,
subscription_request_id=subscription_request_id,
extra=extra,
memo=memo,
)
except (json.JSONDecodeError, IndexError) as e:
logger.warning(f"Failed to deserialize PayPal metadata: {e}")
return FiatSubscriptionPaymentOptions()
+516 -40
View File
@@ -1,28 +1,41 @@
import json
import sys
import traceback
from http import HTTPStatus
from pathlib import Path
import httpx
from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.requests import Request
from loguru import logger
from starlette.routing import Match
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,
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionMeta,
ExtensionPermission,
ExtensionPermissionsGrant,
ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
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,
@@ -32,9 +45,16 @@ from lnbits.core.services.extensions import (
install_extension,
uninstall_extension,
)
from lnbits.core.wasm import WASM_HOST_MANIFEST
from lnbits.core.wasm.extension_host import (
clear_schedules_for_user,
clear_tag_watches_for_user,
)
from lnbits.db import Page
from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
check_admin,
check_user_exists,
)
from lnbits.settings import settings
@@ -142,9 +162,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."
)
@@ -163,38 +184,146 @@ 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,
request: Request,
account_id: AccountId = Depends(check_account_id_exists),
grant: ExtensionPermissionsGrant | None = Body(default=None),
) -> SimpleStatus:
await _ensure_extension_exists(ext_id)
logger.info(f"Enabling extension: {ext_id}.")
ext = await _get_installed_active_extension(ext_id)
user_ext = await _get_or_create_user_extension(account_id.id, ext_id)
required_permissions = _get_required_permissions(ext_id, ext)
granted_permissions = _get_granted_permissions(grant, user_ext)
granted_tags = _get_granted_payment_tags(grant, user_ext)
required_tags = _get_required_payment_tags(ext_id, ext)
if _is_wasm_extension(ext_id, ext):
_ensure_api_permissions_available(
request, _merge_permissions(required_permissions, granted_permissions)
)
_ensure_payment_tags_allowed(required_tags, granted_tags)
_ensure_permissions(required_permissions, granted_permissions)
if grant and grant.permissions:
await _store_granted_permissions(user_ext, granted_permissions)
if grant and grant.payment_tags:
await _store_granted_payment_tags(user_ext, granted_tags)
if account_id.is_admin_id or not ext.requires_payment:
await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
return await _enable_paid_extension(ext_id, ext, user_ext)
async def _ensure_extension_exists(ext_id: str) -> None:
if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
logger.info(f"Enabling extension: {ext_id}.")
async def _get_installed_active_extension(ext_id: str) -> InstallableExtension:
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
return ext
user_ext = await get_user_extension(user.id, ext_id)
async def _get_or_create_user_extension(user_id: str, ext_id: str) -> UserExtension:
user_ext = await get_user_extension(user_id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
user_ext = UserExtension(user=user_id, extension=ext_id, active=False)
await create_user_extension(user_ext)
return user_ext
if user.admin 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.")
def _get_required_permissions(ext_id: str, ext: InstallableExtension) -> list[str]:
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
return [p.id for p in permissions_source] if permissions_source else []
def _get_granted_permissions(
grant: ExtensionPermissionsGrant | None, user_ext: UserExtension
) -> list[str]:
if grant and grant.permissions:
return grant.permissions
if user_ext.extra and user_ext.extra.granted_permissions:
return user_ext.extra.granted_permissions
return []
def _get_required_payment_tags(ext_id: str, ext: InstallableExtension) -> list[str]:
tags_source = (
ext.meta.payment_tags
if ext.meta and ext.meta.payment_tags
else _load_payment_tags_from_config(ext_id)
)
return tags_source if tags_source else []
def _get_granted_payment_tags(
grant: ExtensionPermissionsGrant | None, user_ext: UserExtension
) -> list[str]:
if grant and grant.payment_tags:
return grant.payment_tags
if user_ext.extra and user_ext.extra.granted_payment_tags:
return user_ext.extra.granted_payment_tags
return []
def _ensure_permissions(required: list[str], granted: list[str]) -> None:
if not required:
return
missing = [p for p in required if p not in granted]
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Missing required permissions to enable this extension.",
)
async def _store_granted_permissions(
user_ext: UserExtension, granted_permissions: list[str]
) -> None:
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_permissions = granted_permissions
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
async def _store_granted_payment_tags(
user_ext: UserExtension, granted_tags: list[str]
) -> None:
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_payment_tags = granted_tags
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
async def _activate_user_extension(user_ext: UserExtension) -> None:
user_ext.active = True
await update_user_extension(user_ext)
async def _enable_paid_extension(
ext_id: str, ext: InstallableExtension, user_ext: UserExtension
) -> SimpleStatus:
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
)
if user_ext.is_paid:
user_ext.active = True
await update_user_extension(user_ext)
await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
@@ -211,31 +340,137 @@ async def api_enable_extension(
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext)
await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
@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."
)
logger.info(f"Disabling extension: {ext_id}.")
user_ext.active = False
if user_ext.extra and user_ext.extra.granted_permissions:
user_ext.extra.granted_permissions = []
if user_ext.extra and user_ext.extra.granted_payment_tags:
user_ext.extra.granted_payment_tags = []
await update_user_extension(user_ext)
ext = await get_installed_extension(ext_id)
if ext and ext.meta and ext.meta.extension_type == "wasm":
await clear_tag_watches_for_user(ext_id, account_id.id)
await clear_schedules_for_user(ext_id, account_id.id)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.put("/{ext_id}/permissions")
async def api_update_extension_permissions(
ext_id: str,
request: Request,
account_id: AccountId = Depends(check_account_id_exists),
grant: ExtensionPermissionsGrant | None = Body(default=None),
) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
required_permissions = (
[p.id for p in permissions_source] if permissions_source else []
)
granted_permissions = grant.permissions if grant and grant.permissions else []
granted_tags = grant.payment_tags if grant and grant.payment_tags else []
if _is_wasm_extension(ext_id, ext):
_ensure_api_permissions_available(
request, _merge_permissions(required_permissions, granted_permissions)
)
_ensure_payment_tags_allowed(
_get_required_payment_tags(ext_id, ext), granted_tags
)
if required_permissions:
missing = [p for p in required_permissions if p not in granted_permissions]
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Missing required permissions to save for this extension.",
)
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_permissions = granted_permissions
user_ext_info.granted_payment_tags = granted_tags
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Permissions saved for '{ext_id}'.")
@extension_router.get("/{ext_id}/capabilities")
async def api_extension_capabilities(
ext_id: str,
request: Request,
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
await _ensure_extension_exists(ext_id)
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
permissions = [p.id for p in permissions_source] if permissions_source else []
missing = (
_missing_api_permissions(request, permissions)
if _is_wasm_extension(ext_id, ext)
else []
)
payment_tags = _get_required_payment_tags(ext_id, ext)
return {
"ok": True,
"extension": ext_id,
"is_wasm": _is_wasm_extension(ext_id, ext),
"permissions": permissions,
"missing_permissions": missing,
"payment_tags": payment_tags,
}
@extension_router.get("/wasm/manifest")
async def api_wasm_manifest(
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
return WASM_HOST_MANIFEST
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
@@ -376,7 +611,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(
@@ -422,9 +659,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
@@ -435,7 +672,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:
@@ -456,15 +693,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(
@@ -498,11 +738,24 @@ async def delete_extension_db(ext_id: str):
# TODO: create a response model for this
@extension_router.get("/all")
async def extensions(user: User = Depends(check_user_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
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
)
user_exts = await get_user_extensions(account_id.id, 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)
installable_exts = await InstallableExtension.get_installable_extensions()
installed_exts_ids = [e.id for e in installed_exts]
user_exts_map = {e.extension: e for e in user_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]
@@ -510,7 +763,7 @@ async def extensions(user: User = Depends(check_user_exists)):
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:
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
@@ -527,10 +780,10 @@ async def extensions(user: User = Depends(check_user_exists)):
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()
if e.meta and not e.meta.permissions:
e.meta.permissions = _load_permissions_from_config(e.id)
if e.meta and not e.meta.payment_tags:
e.meta.payment_tags = _load_payment_tags_from_config(e.id)
extension_data = [
{
@@ -548,6 +801,27 @@ async def extensions(user: User = Depends(check_user_exists)):
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"permissions": (
[dict(p) for p in ext.meta.permissions]
if ext.meta and ext.meta.permissions
else [dict(p) for p in _load_permissions_from_config(ext.id)]
),
"paymentTags": (
ext.meta.payment_tags
if ext.meta and ext.meta.payment_tags
else _load_payment_tags_from_config(ext.id)
),
"kvSchema": _load_kv_schema_from_config(ext.id),
"grantedPermissions": (
user_ext.extra.granted_permissions
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
else []
),
"grantedPaymentTags": (
user_ext.extra.granted_payment_tags
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
else []
),
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
@@ -573,3 +847,205 @@ async def extensions(user: User = Depends(check_user_exists)):
for ext in installable_exts
]
return extension_data
def _load_permissions_from_config(ext_id: str) -> list[ExtensionPermission]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return []
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
permissions = config_json.get("permissions", [])
return [ExtensionPermission(**p) for p in permissions]
except Exception:
return []
def _load_kv_schema_from_config(ext_id: str) -> dict:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return {}
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
schema = config_json.get("kv_schema", {})
return schema if isinstance(schema, dict) else {}
except Exception:
return {}
def _load_payment_tags_from_config(ext_id: str) -> list[str]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return []
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
tags = config_json.get("payment_tags", [])
return tags if isinstance(tags, list) else []
except Exception:
return []
def _merge_permissions(required: list[str], granted: list[str]) -> list[str]:
merged = set()
for perm in required or []:
merged.add(perm)
for perm in granted or []:
merged.add(perm)
return list(merged)
def _ensure_payment_tags_allowed(required: list[str], granted: list[str]) -> None:
if not required and granted:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"This extension does not declare any payment tags.",
)
if required and not granted:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Select at least one payment tag before enabling this extension.",
)
if not required or not granted:
return
invalid = [t for t in granted if t not in required]
if invalid:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Invalid payment tags requested: {', '.join(invalid)}",
)
def _ensure_api_permissions_available(request: Request, permissions: list[str]) -> None:
if not permissions:
return
missing = []
for perm in permissions:
parsed = _parse_api_permission(perm)
if not parsed:
continue
method, path = parsed
if not _route_exists(request, method, path):
missing.append(perm)
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Permissions reference missing endpoints: {', '.join(missing)}",
)
def _parse_api_permission(perm: str) -> tuple[str, str] | None:
if not perm.startswith("api."):
return None
try:
method_part, path = perm.split(":", 1)
method = method_part.replace("api.", "").upper()
except ValueError:
return None
if not path.startswith("/"):
return None
return method, path
def _missing_api_permissions(request: Request, permissions: list[str]) -> list[str]:
missing = []
for perm in permissions or []:
parsed = _parse_api_permission(perm)
if not parsed:
continue
method, path = parsed
if not _route_exists(request, method, path):
missing.append(perm)
return missing
def _route_exists(request: Request, method: str, path: str) -> bool:
scope = {
"type": "http",
"method": method,
"path": path,
"root_path": "",
"headers": [],
}
for route in request.app.router.routes:
methods = getattr(route, "methods", None)
if methods and method not in methods:
continue
try:
match, _ = route.matches(scope)
except Exception as exc:
logger.debug(f"Route match failed for {method} {path}: {exc!s}")
continue
if match == Match.FULL:
return True
return False
def _is_wasm_extension(ext_id: str, ext: InstallableExtension) -> bool:
if ext.meta and ext.meta.extension_type == "wasm":
return True
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return False
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type") == "wasm"
except Exception:
return False
@extension_router.get(
"/reviews/tags",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews_tags() -> list[ExtensionReviewsStatus]:
async with httpx.AsyncClient() as client:
resp = await client.get(settings.lnbits_extensions_reviews_url + "/tags")
resp.raise_for_status()
data = resp.json()
return [ExtensionReviewsStatus(**item) for item in data]
@extension_router.get(
"/reviews/{ext_id}",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews(ext_id: str, request: Request) -> Page[ExtensionReview]:
async with httpx.AsyncClient() as client:
query_string = str(request.query_params)
resp = await client.get(
settings.lnbits_extensions_reviews_url + f"/reviews/{ext_id}?{query_string}"
)
resp.raise_for_status()
reviews = resp.json()
return Page(
data=[ExtensionReview(**item) for item in reviews["data"]],
total=reviews["total"],
)
@extension_router.put(
"/reviews",
dependencies=[Depends(check_account_exists)],
)
async def create_extension_review(
data: CreateExtensionReview,
) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client:
resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
)
resp.raise_for_status()
payment_request = resp.json()
return ExtensionReviewPaymentRequest(**payment_request)
+8 -8
View File
@@ -7,7 +7,6 @@ from fastapi.responses import FileResponse
from lnbits.core.models import (
SimpleStatus,
User,
)
from lnbits.core.models.extensions import (
Extension,
@@ -16,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,
@@ -26,9 +26,9 @@ from lnbits.core.services.extensions_builder import (
zip_directory,
)
from lnbits.decorators import (
check_account_id_exists,
check_admin,
check_extension_builder,
check_user_exists,
)
from ..crud import (
@@ -84,9 +84,9 @@ async def api_build_extension(data: ExtensionData) -> FileResponse:
)
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
@@ -110,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
@@ -128,10 +128,10 @@ async def api_deploy_extension(
)
async def api_preview_extension(
data: ExtensionData,
user: User = Depends(check_user_exists),
account_id: AccountId = Depends(check_account_id_exists),
) -> SimpleStatus:
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.")
+35 -15
View File
@@ -1,6 +1,7 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import WalletTypeInfo
@@ -30,6 +31,11 @@ async def create_subscription(
data: CreateFiatSubscription,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> FiatSubscriptionResponse:
logger.debug(
f"Creating subscription with provider '{provider}. '"
f"Subscription ID '{data.subscription_id}'."
)
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
raise HTTPException(404, "Fiat provider not found")
@@ -47,6 +53,12 @@ async def create_subscription(
subscription_response = await fiat_provider.create_subscription(
data.subscription_id, data.quantity, data.payment_options
)
if subscription_response.error_message:
logger.warning(
f"Failed to create subscription with provider '{provider}': "
f"{subscription_response.error_message}"
)
return subscription_response
@@ -75,20 +87,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
+21 -37
View File
@@ -42,60 +42,44 @@ async def robots():
@generic_router.get(
"/extensions/builder/preview/{ext_id}",
"/extensions/builder/preview/{ext_id}/{resource}",
name="extensions builder",
dependencies=[Depends(check_extension_builder)],
)
async def extensions_builder_preview(
request: Request,
ext_id: str,
resource: str | None = None,
page_name: str | None = None,
user: User = Depends(check_user_exists),
) -> HTMLResponse:
) -> FileResponse:
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html"
if page_name == "public_page":
html_file_name = "public_page.html"
html_file_path = Path(
file_name = "index"
if page_name == "public_page":
file_name = "public_page"
resource_path = Path(
settings.extension_builder_working_dir_path,
"extension_builder_stub",
ext_id,
working_dir_name,
ext_id,
"templates",
ext_id,
html_file_name,
"static",
)
html_file_full_path = Path(
settings.extension_builder_working_dir_path, html_file_path
)
file_ext = ".vue" if resource == "template" else ".js"
file_full_path = Path(resource_path, file_name + file_ext)
if not html_file_full_path.is_file():
return template_renderer().TemplateResponse(
request,
"error.html",
{
"err": f"Extension {ext_id} not found",
"message": "Please 'Refresh Preview' first.",
},
status_code=HTTPStatus.NOT_FOUND,
)
response = template_renderer().TemplateResponse(
request,
html_file_path.as_posix(),
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
if not file_full_path.is_file():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
response = FileResponse(file_full_path.absolute().as_posix())
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
)
return response
@@ -197,6 +181,7 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@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")
@@ -207,6 +192,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
)
@generic_router.get(
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
@@ -223,7 +211,7 @@ async def index(
@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_public.html")
return template_renderer().TemplateResponse(request, "index.html", {"public": True})
@generic_router.get("/uuidv4/{hex_value}")
@@ -284,7 +272,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"
+3 -3
View File
@@ -24,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
@@ -48,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
@@ -64,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
+48 -43
View File
@@ -18,6 +18,7 @@ from lnbits.core.crud.payments import (
update_payment,
)
from lnbits.core.crud.users import get_account
from lnbits.core.db import db
from lnbits.core.models import (
CancelInvoice,
CreateInvoice,
@@ -35,14 +36,16 @@ from lnbits.core.models import (
SimpleStatus,
)
from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import User
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,
@@ -67,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"])
@@ -82,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,
@@ -101,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)
@@ -118,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)
@@ -138,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)
@@ -157,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)
@@ -178,24 +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,
)
if not recheck_pending:
return page
) -> 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
for payment in page.data:
if payment.pending:
await update_pending_payment(payment)
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
return Page(data=payments, total=page.total)
@payment_router.get(
@@ -208,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(
@@ -243,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,
@@ -274,9 +280,8 @@ async def api_payments_create(
async def api_update_payment_labels(
payment_hash: str,
data: UpdatePaymentLabels,
key_type: WalletTypeInfo = Depends(require_admin_key),
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.")
@@ -332,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}
@@ -341,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."
+51 -15
View File
@@ -20,6 +20,8 @@ from lnbits.core.crud import (
update_admin_settings,
update_wallet,
)
from lnbits.core.crud.users import clear_user_id_cache, get_account, update_account
from lnbits.core.crud.wallets import delete_wallet_by_id
from lnbits.core.models import (
AccountFilters,
AccountOverview,
@@ -72,7 +74,7 @@ async def api_get_users(
summary="Get user by Id",
)
async def api_get_user(user_id: str) -> User:
user = await get_user(user_id)
user = await get_user(user_id, active_only=False)
if not user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return user
@@ -115,12 +117,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,14 +156,11 @@ 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:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete user with wallets.",
)
for wallet in wallets:
await delete_wallet_by_id(wallet.id)
if user_id == settings.super_user:
raise HTTPException(
@@ -169,7 +168,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.",
@@ -199,7 +198,7 @@ async def api_users_reset_password(user_id: str) -> str:
return f"reset_key_{reset_key_b64}"
@users_router.get(
@users_router.put(
"/user/{user_id}/admin",
dependencies=[Depends(check_super_user)],
name="Give or revoke admin permsisions to a user",
@@ -222,9 +221,46 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
)
@users_router.put(
"/user/{user_id}/activate",
name="Activate or deactivate a user",
)
async def api_users_toggle_activated(
user_id: str, admin_account: Account = Depends(check_admin)
) -> SimpleStatus:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot deactivate super user.",
)
if user_id == admin_account.id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="You cannot deactivate yourself.",
)
if settings.is_admin_user(user_id):
settings.lnbits_admin_users.remove(user_id)
user_account = await get_account(user_id, active_only=False)
if not user_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="User not found.",
)
user_account.activated = not user_account.activated
await update_account(user_account)
await clear_user_id_cache(user_id)
return SimpleStatus(
success=True,
message=f"User {'activated' if user_account.activated else 'deactivated'}.",
)
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
return await get_wallets(user_id)
return await get_wallets(user_id, deleted=None)
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
@@ -247,7 +283,7 @@ async def api_users_create_user_wallet(
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
)
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
wal = await get_wallet(wallet)
wal = await get_wallet(wallet, deleted=True)
if not wal:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
@@ -295,7 +331,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 +340,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.",
+25 -18
View File
@@ -9,12 +9,14 @@ from fastapi import (
)
from lnbits.core.crud.wallets import (
clear_wallet_cache,
create_wallet,
get_wallets_paginated,
)
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
from lnbits.core.models import CreateWallet, KeyType, Wallet, WalletTypeInfo
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import Account, AccountId
from lnbits.core.models.wallets import (
WalletsFilters,
WalletSharePermission,
@@ -29,7 +31,8 @@ from lnbits.core.services.wallets import (
)
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,
@@ -65,11 +68,11 @@ 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,
)
@@ -85,7 +88,7 @@ async def api_invite_wallet_share(
@wallet_router.delete("/share/invite/{share_request_id}")
async def api_reject_wallet_invitation(
share_request_id: str, invited_user: User = Depends(check_user_exists)
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.")
@@ -124,12 +127,15 @@ 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")
clear_wallet_cache(wallet)
wallet.adminkey = uuid4().hex
wallet.inkey = uuid4().hex
await update_wallet(wallet)
@@ -175,10 +181,10 @@ async def api_update_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(
@@ -189,11 +195,14 @@ 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:
if data.wallet_type == WalletType.LIGHTNING:
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:
@@ -202,11 +211,9 @@ async def api_create_wallet(
"Shared wallet ID is required for shared wallets.",
)
return await create_lightning_shared_wallet(
user_id=key_info.wallet.user,
user_id=account_id.id,
source_wallet_id=data.shared_wallet_id,
)
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Unknown wallet type: {data.wallet_type}.",
)
# 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)
+26
View File
@@ -1,5 +1,7 @@
from fastapi import APIRouter, WebSocket
from ..crud.wallets import get_wallet_for_key
from ..models import KeyType
from ..services import websocket_manager
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
@@ -11,6 +13,30 @@ async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
await websocket_manager.listen(conn)
@websocket_router.websocket("/tag/{tag}")
async def websocket_connect_tag(websocket: WebSocket, tag: str) -> None:
api_key = websocket.headers.get("X-API-KEY") or websocket.query_params.get(
"api-key"
)
if not api_key:
await websocket.close(code=4401)
return
wallet = await get_wallet_for_key(api_key)
if not wallet:
await websocket.close(code=4404)
return
key_type = KeyType.admin if wallet.adminkey == api_key else KeyType.invoice
if key_type not in {KeyType.admin, KeyType.invoice}:
await websocket.close(code=4403)
return
item_id = f"tag:{wallet.id}:{tag}"
conn = await websocket_manager.connect(item_id, websocket)
await websocket_manager.listen(conn)
@websocket_router.post("/{item_id}")
async def websocket_update_post(item_id: str, data: str):
try:
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host.extension_host import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host.runner import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host.service import * # noqa: F401,F403
+9 -37
View File
@@ -12,7 +12,6 @@ from typing import Any, Generic, Literal, TypeVar, get_origin
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text
@@ -56,14 +55,6 @@ def compat_timestamp_placeholder(key: str):
return f":{key}"
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime:
return compat_timestamp_placeholder(field)
else:
return f":{field}"
class Compat:
type: str | None = "<inherited>"
schema: str | None = "<inherited>"
@@ -326,30 +317,7 @@ class Database(Compat):
self.engine: AsyncEngine = create_async_engine(
database_uri, echo=settings.debug_database
)
if self.type in {POSTGRES, COCKROACH}:
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_date(value) -> datetime:
if value is None:
value = "1970-01-01 00:00:00"
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return datetime.strptime(value, f)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime,
decoder=_parse_date,
schema="pg_catalog",
)
)
self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}")
@asynccontextmanager
@@ -662,7 +630,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
placeholders = []
keys = model_to_dict(model).keys()
for field in keys:
placeholders.append(get_placeholder(model, field))
placeholders.append(f":{field}")
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields = ", ".join([f'"{key}"' for key in keys])
values = ", ".join(placeholders)
@@ -680,9 +648,8 @@ def update_query(
"""
fields = []
for field in model_to_dict(model).keys():
placeholder = get_placeholder(model, field)
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields.append(f'"{field}" = {placeholder}')
fields.append(f'"{field}" = :{field}')
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
@@ -700,7 +667,12 @@ def model_to_dict(model: BaseModel) -> dict:
if model.__fields__[key].field_info.extra.get("no_database", False):
continue
if isinstance(value, datetime):
_dict[key] = value.timestamp()
if DB_TYPE == SQLITE:
_dict[key] = value.timestamp()
else:
# remove tz. postgres and cockroach TIMESTAMP is not tz aware
# so it will throw if we dont remove the UTC.
_dict[key] = value.replace(tzinfo=None)
continue
if (
type(type_) is type(BaseModel)
@@ -756,7 +728,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value
_dict[key] = value.replace(tzinfo=timezone.utc)
continue
if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
+222 -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,96 @@ 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,
)
cache.set(f"auth:user:cache_key:{sha256s(account.id)}", cache_key)
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 +350,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 +439,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 +458,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 +476,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.")
+4 -2
View File
@@ -109,7 +109,7 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
logger.error(f"RequestValidationError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
content={"detail": [dict(e) for e in exc.errors()]},
)
@app.exception_handler(HTTPException)
@@ -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",
]
+383
View File
@@ -0,0 +1,383 @@
import asyncio
import json
import time
from collections.abc import AsyncGenerator
from typing import Any, Literal
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,
)
FiatMethod = Literal["checkout", "subscription"]
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 PayPalSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
checking_id: str | None = None
payment_request: str | None = None
class PayPalCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout"
checkout: PayPalCheckoutOptions | None = None
subscription: PayPalSubscriptionOptions | 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")
if opts.fiat_method == "subscription":
term = opts.subscription or PayPalSubscriptionOptions()
checking_id = term.checking_id or urlsafe_short_hash()
return FiatInvoiceResponse(
ok=True,
checking_id=f"subscription_{checking_id}",
payment_request=term.payment_request or "",
)
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"
)
logger.debug(f"Creating PayPal subscription with ID '{subscription_id}'")
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,
"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=data.get("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:
logger.debug(
f"Cancelling PayPal subscription '{subscription_id}'. "
f"Correlation ID '{correlation_id}'."
)
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()
paypal_id = self._normalize_paypal_id(checking_id)
if paypal_id.startswith("subscription_"):
capture_id = paypal_id.replace("subscription_", "", 1)
r = await self.client.get(
f"v2/payments/captures/{capture_id}", headers=self._auth_headers()
)
r.raise_for_status()
return self._status_from_capture(r.json())
else:
r = await self.client.get(
f"/v2/checkout/orders/{paypal_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 _status_from_capture(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("status") or "").upper()
if status in ["COMPLETED"]:
return FiatPaymentSuccessStatus()
if status in ["DECLINED", "FAILED", "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:
meta = [
payment_options.wallet_id,
payment_options.tag,
payment_options.subscription_request_id,
]
if payment_options.extra:
meta.append(payment_options.extra.get("link", None))
# Keep custom_id within PayPal's 127-char limit
memo_limit = 120 - len(json.dumps(meta))
if memo_limit > 0 and payment_options.memo:
meta.append(payment_options.memo[:memo_limit])
return json.dumps(meta)
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
)
+17 -48
View File
@@ -68,55 +68,13 @@ 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,
"AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
"AD_SPACE_TITLE": settings.lnbits_ad_space_title,
"EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
"HIDE_API": settings.lnbits_hide_api,
"SITE_TITLE": settings.lnbits_site_title,
"SITE_TAGLINE": settings.lnbits_site_tagline,
"SITE_DESCRIPTION": settings.lnbits_site_description,
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
"LNBITS_AUTH_KEYCLOAK_ORG": settings.keycloak_client_custom_org,
"LNBITS_AUTH_KEYCLOAK_ICON": settings.keycloak_client_custom_icon,
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
"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_SERVICE_FEE": settings.lnbits_service_fee,
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
"LNBITS_VERSION": settings.version,
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
"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,
}
t.env.globals["WINDOW_SETTINGS"] = window_settings
for key, value in window_settings.items():
t.env.globals[key] = value
# used in base.html
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True)
t.env.globals["CURRENCIES"] = list(currencies.keys())
if settings.bundle_assets:
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
@@ -130,6 +88,9 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
# backwards compatibility for extensions (tpos)
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
return t
@@ -387,6 +348,14 @@ def camel_to_snake(name: str) -> str:
return name.lower()
def snake_to_camel(name: str, capitalize_first: bool | None = False) -> str:
components = name.split("_")
camel = components[0] + "".join(x.capitalize() for x in components[1:])
if capitalize_first:
camel = camel[0].upper() + camel[1:]
return camel
def is_camel_case(v: str) -> bool:
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
+198 -4
View File
@@ -41,6 +41,14 @@ class UsersSettings(LNbitsSettings):
lnbits_admin_users: list[str] = Field(default=[])
lnbits_allowed_users: list[str] = Field(default=[])
lnbits_allow_new_accounts: bool = Field(default=True)
lnbits_require_user_activation: bool = Field(default=False)
lnbits_user_activation_by_email: bool = Field(default=False)
lnbits_user_activation_by_payment: bool = Field(default=False)
lnbits_user_activation_by_invitation_code: bool = Field(default=False)
lnbits_register_reusable_activation_code: str = Field(default="")
lnbits_register_one_time_activation_codes: list[str] = Field(default=[])
@property
def new_accounts_allowed(self) -> bool:
@@ -52,6 +60,13 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_extensions_reviews_url: str = Field(
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
description="""
URL for the paid reviews.
Regular users can view this URL (not secret).
""",
)
lnbits_extensions_manifests: list[str] = Field(
default=[
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
@@ -245,6 +260,11 @@ class ThemesSettings(LNbitsSettings):
)
lnbits_show_home_page_elements: bool = Field(default=True)
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
lnbits_wallet_featured_button_label: str | None = Field(default=None)
lnbits_wallet_featured_button_url: str | None = Field(default=None)
lnbits_wallet_featured_button_icon: str | None = Field(default=None)
lnbits_custom_badge: str | None = Field(default=None)
lnbits_custom_badge_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field(
@@ -270,11 +290,16 @@ 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")
lnbits_default_gradient: bool = Field(default=True)
lnbits_default_bgimage: str | None = Field(default=None)
lnbits_default_dark: bool = Field(default=True)
lnbits_default_card_rounded: bool = Field(default=False)
lnbits_default_card_gradient: bool = Field(default=False)
lnbits_default_card_shadow: bool = Field(default=False)
class OpsSettings(LNbitsSettings):
@@ -298,6 +323,10 @@ class AssetSettings(LNbitsSettings):
"heic",
"heif",
"heics",
"text/plain",
"text/json" "text/xml",
"application/json",
"application/pdf",
]
)
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -307,6 +336,12 @@ class AssetSettings(LNbitsSettings):
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):
lnbits_reserve_fee_min: int = Field(default=2000, ge=0)
@@ -571,6 +606,15 @@ class SparkFundingSource(LNbitsSettings):
spark_token: str | None = Field(default=None)
class SparkL2FundingSource(LNbitsSettings):
spark_l2_network: str = Field(default="MAINNET")
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
class LnTipsFundingSource(LNbitsSettings):
lntips_api_endpoint: str | None = Field(default=None)
lntips_api_key: str | None = Field(default=None)
@@ -600,7 +644,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)
@@ -641,6 +684,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)
@@ -663,6 +720,7 @@ class FundingSourcesSettings(
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
SparkL2FundingSource,
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
@@ -676,7 +734,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.
@@ -685,7 +743,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]:
@@ -699,7 +758,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:
@@ -756,6 +820,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
@@ -940,6 +1005,7 @@ class EnvSettings(LNbitsSettings):
enable_log_to_file: bool = Field(default=True)
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
first_install_token: str | None = Field(default=None)
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
@@ -983,6 +1049,7 @@ class SuperUserSettings(LNbitsSettings):
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"SparkL2Wallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
@@ -1088,6 +1155,133 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
and ext_id in self.lnbits_all_extensions_ids
)
def to_public(self) -> PublicSettings:
return PublicSettings.from_settings(self)
class PublicSettings(BaseModel):
"""
PublicSettings is used for templating and public information.
WARNING: do not expose private information, PublicSettings is exposed publicly.
"""
allow_register: bool = Field(alias="allowRegister")
qr_logo: str = Field(alias="qrLogo")
site_title: str = Field(alias="siteTitle")
site_tagline: str = Field(alias="siteTagline")
site_description: str | None = Field(alias="siteDescription")
auth_methods: list[str] = Field(alias="authMethods")
keycloak_org: str | None = Field(alias="keycloakOrg")
keycloak_icon: str | None = Field(alias="keycloakIcon")
has_holdinvoice: bool = Field(alias="hasHoldinvoice")
has_nodemanager: bool = Field(alias="hasNodemanager")
show_nodemanager: bool = Field(alias="showNodemanager")
custom_logo: str | None = Field(alias="customLogo")
show_voidwallet: bool = Field(alias="showVoidwallet")
version: str = Field(alias="version")
show_home_page_elements: bool = Field(alias="showHomePageElements")
hide_api: bool = Field(alias="hideApi")
cache_key: str = Field(alias="cacheKey")
webpush_pubkey: str | None = Field(alias="webpushPubkey")
show_extensions: bool = Field(alias="showExtensions")
show_audit: bool = Field(alias="showAudit")
show_admin: bool = Field(alias="showAdmin")
ad_space: list[list[str]] = Field(alias="adSpace")
ad_space_title: str = Field(alias="adSpaceTitle")
show_ad_space: bool = Field(alias="showAdSpace")
custom_image: str | None = Field(alias="customImage")
custom_badge: str | None = Field(alias="customBadge")
custom_badge_color: str | None = Field(alias="customBadgeColor")
service_fee: float = Field(alias="serviceFee")
service_fee_max: int = Field(alias="serviceFeeMax")
service_fee_wallet: str | None = Field(alias="serviceFeeWallet")
theme_options: list[str] = Field(alias="themeOptions")
default_reaction: str = Field(alias="defaultReaction")
default_theme: str = Field(alias="defaultTheme")
default_border: str = Field(alias="defaultBorder")
default_bgimage: str | None = Field(alias="defaultBgimage")
default_gradient: bool = Field(alias="defaultGradient")
default_dark: bool = Field(alias="defaultDark")
default_card_rounded: bool = Field(alias="defaultCardRounded")
default_card_gradient: bool = Field(alias="defaultCardGradient")
default_card_shadow: bool = Field(alias="defaultCardShadow")
denomination: str | None = Field()
extensions: list[str] = Field()
allowed_currencies: list[str] = Field(alias="allowedCurrencies")
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
ext_builder: bool = Field(alias="extBuilder")
nostr_configured: bool = Field(alias="nostrConfigured")
telegram_configured: bool = Field(alias="telegramConfigured")
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
wallet_featured_button_icon: str | None = Field(alias="walletFeaturedButtonIcon")
lnbits_user_activation_by_email: bool = Field(alias="userActivationByEmail")
lnbits_user_activation_by_payment: bool = Field(alias="userActivationByPayment")
lnbits_user_activation_by_invitation_code: bool = Field(
alias="userActivationByInvitationCode"
)
has_first_install_token: bool = Field(alias="hasFirstInstallToken")
@classmethod
def from_settings(cls, settings: Settings):
ads = [x.split(";") for x in settings.lnbits_ad_space.split(",")]
return cls(
adSpace=ads,
adSpaceTitle=settings.lnbits_ad_space_title,
showAdSpace=settings.lnbits_ad_space_enabled and len(ads) > 0,
allowRegister=settings.new_accounts_allowed,
qrLogo=settings.lnbits_qr_logo,
siteDescription=settings.lnbits_site_description,
siteTitle=settings.lnbits_site_title,
siteTagline=settings.lnbits_site_tagline,
authMethods=settings.auth_allowed_methods,
keycloakOrg=settings.keycloak_client_custom_org,
keycloakIcon=settings.keycloak_client_custom_icon,
hasHoldinvoice=settings.has_holdinvoice,
hasNodemanager=settings.has_nodemanager,
showNodemanager=settings.lnbits_node_ui and settings.has_nodemanager,
customLogo=settings.lnbits_custom_logo,
showVoidwallet=settings.lnbits_backend_wallet_class == "VoidWallet",
version=settings.version,
showHomePageElements=settings.lnbits_show_home_page_elements,
hideApi=settings.lnbits_hide_api,
cacheKey=str(settings.server_startup_time),
webpushPubkey=settings.lnbits_webpush_pubkey,
showExtensions=not settings.lnbits_extensions_deactivate_all,
showAudit=settings.lnbits_audit_enabled,
showAdmin=settings.lnbits_admin_ui,
customImage=settings.lnbits_custom_image,
customBadge=settings.lnbits_custom_badge,
customBadgeColor=settings.lnbits_custom_badge_color,
serviceFee=settings.lnbits_service_fee,
serviceFeeMax=settings.lnbits_service_fee_max,
serviceFeeWallet=settings.lnbits_service_fee_wallet,
themeOptions=settings.lnbits_theme_options,
defaultReaction=settings.lnbits_default_reaction,
defaultTheme=settings.lnbits_default_theme,
defaultBorder=settings.lnbits_default_border,
defaultGradient=settings.lnbits_default_gradient,
defaultBgimage=settings.lnbits_default_bgimage,
defaultDark=settings.lnbits_default_dark,
defaultCardRounded=settings.lnbits_default_card_rounded,
defaultCardGradient=settings.lnbits_default_card_gradient,
defaultCardShadow=settings.lnbits_default_card_shadow,
denomination=settings.lnbits_denomination,
extensions=list(settings.lnbits_installed_extensions_ids),
allowedCurrencies=settings.lnbits_allowed_currencies,
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
nostrConfigured=settings.is_nostr_notifications_configured(),
telegramConfigured=settings.is_telegram_notifications_configured(),
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon,
userActivationByEmail=settings.lnbits_user_activation_by_email,
userActivationByPayment=settings.lnbits_user_activation_by_payment,
userActivationByInvitationCode=settings.lnbits_user_activation_by_invitation_code,
hasFirstInstallToken=settings.first_install_token is not None,
)
class SuperSettings(EditableSettings):
super_user: str
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
+19 -17
View File
File diff suppressed because one or more lines are too long
+185 -1
View File
@@ -217,7 +217,182 @@ body.body--dark .q-header,
body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
backdrop-filter: blur(6px);
backdrop-filter: blur(6px) brightness(0.8);
}
body.rounded-ui .q-card,
body.rounded-ui .q-btn {
border-radius: 10px;
}
body[data-theme=classic].card-gradient .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgba(103, 58, 183, 0.08), rgba(156, 39, 176, 0.06));
}
body[data-theme=classic].card-gradient .q-drawer {
background-image: linear-gradient(165deg, rgba(103, 58, 183, 0.06), rgba(156, 39, 176, 0.04));
}
body[data-theme=classic].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgb(38.6192771084, 42.356626506, 64.7807228916), rgba(103, 58, 183, 0.07));
}
body[data-theme=classic].card-gradient.body--dark .q-drawer {
background-image: linear-gradient(165deg, rgb(36.7144578313, 40.2674698795, 61.5855421687), rgba(103, 58, 183, 0.05));
}
body[data-theme=bitcoin].card-gradient .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgba(234, 97, 29, 0.08), rgba(229, 111, 53, 0.06));
}
body[data-theme=bitcoin].card-gradient .q-drawer {
background-image: linear-gradient(165deg, rgba(234, 97, 29, 0.06), rgba(229, 111, 53, 0.04));
}
body[data-theme=bitcoin].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgb(54.18, 49.364, 71.036), rgba(234, 97, 29, 0.07));
}
body[data-theme=bitcoin].card-gradient.body--dark .q-drawer {
background-image: linear-gradient(165deg, rgb(51.885, 47.273, 68.027), rgba(234, 97, 29, 0.05));
}
body[data-theme=freedom].card-gradient .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgba(226, 33, 86, 0.08), rgba(185, 26, 69, 0.06));
}
body[data-theme=freedom].card-gradient .q-drawer {
background-image: linear-gradient(165deg, rgba(226, 33, 86, 0.06), rgba(185, 26, 69, 0.04));
}
body[data-theme=freedom].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgb(82.2051282051, 55.1948717949, 63.4153846154), rgba(226, 33, 86, 0.07));
}
body[data-theme=freedom].card-gradient.body--dark .q-drawer {
background-image: linear-gradient(165deg, rgb(79.1538461538, 53.1461538462, 61.0615384615), rgba(226, 33, 86, 0.05));
}
body[data-theme=cyber].card-gradient .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgba(124, 179, 66, 0.08), rgba(85, 139, 47, 0.06));
}
body[data-theme=cyber].card-gradient .q-drawer {
background-image: linear-gradient(165deg, rgba(124, 179, 66, 0.06), rgba(85, 139, 47, 0.04));
}
body[data-theme=cyber].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgb(10.2, 10.2, 10.2), rgba(124, 179, 66, 0.07));
}
body[data-theme=cyber].card-gradient.body--dark .q-drawer {
background-image: linear-gradient(165deg, rgb(7.65, 7.65, 7.65), rgba(124, 179, 66, 0.05));
}
body[data-theme=mint].card-gradient .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgba(58, 183, 125, 0.08), rgba(39, 176, 101, 0.06));
}
body[data-theme=mint].card-gradient .q-drawer {
background-image: linear-gradient(165deg, rgba(58, 183, 125, 0.06), rgba(39, 176, 101, 0.04));
}
body[data-theme=mint].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgb(38.6192771084, 64.7807228916, 53.5686746988), rgba(58, 183, 125, 0.07));
}
body[data-theme=mint].card-gradient.body--dark .q-drawer {
background-image: linear-gradient(165deg, rgb(36.7144578313, 61.5855421687, 50.9265060241), rgba(58, 183, 125, 0.05));
}
body[data-theme=autumn].card-gradient .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgba(183, 118, 58, 0.08), rgba(176, 121, 39, 0.06));
}
body[data-theme=autumn].card-gradient .q-drawer {
background-image: linear-gradient(165deg, rgba(183, 118, 58, 0.06), rgba(176, 121, 39, 0.04));
}
body[data-theme=autumn].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgb(64.7807228916, 51.0771084337, 38.6192771084), rgba(183, 118, 58, 0.07));
}
body[data-theme=autumn].card-gradient.body--dark .q-drawer {
background-image: linear-gradient(165deg, rgb(61.5855421687, 48.5578313253, 36.7144578313), rgba(183, 118, 58, 0.05));
}
body[data-theme=flamingo].card-gradient .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgba(255, 0, 255, 0.08), rgba(253, 163, 253, 0.06));
}
body[data-theme=flamingo].card-gradient .q-drawer {
background-image: linear-gradient(165deg, rgba(255, 0, 255, 0.06), rgba(253, 163, 253, 0.04));
}
body[data-theme=flamingo].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgb(66.176, 4.224, 66.176), rgba(255, 0, 255, 0.07));
}
body[data-theme=flamingo].card-gradient.body--dark .q-drawer {
background-image: linear-gradient(165deg, rgb(61.382, 3.918, 61.382), rgba(255, 0, 255, 0.05));
}
body[data-theme=monochrome].card-gradient .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgba(73, 73, 73, 0.08), rgba(107, 107, 107, 0.06));
}
body[data-theme=monochrome].card-gradient .q-drawer {
background-image: linear-gradient(165deg, rgba(73, 73, 73, 0.06), rgba(107, 107, 107, 0.04));
}
body[data-theme=monochrome].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgb(10.2, 10.2, 10.2), rgba(73, 73, 73, 0.07));
}
body[data-theme=monochrome].card-gradient.body--dark .q-drawer {
background-image: linear-gradient(165deg, rgb(7.65, 7.65, 7.65), rgba(73, 73, 73, 0.05));
}
body[data-theme=salvador].card-gradient .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgba(25, 118, 210, 0.08), rgba(38, 166, 154, 0.06));
}
body[data-theme=salvador].card-gradient .q-drawer {
background-image: linear-gradient(165deg, rgba(25, 118, 210, 0.06), rgba(38, 166, 154, 0.04));
}
body[data-theme=salvador].card-gradient.body--dark .q-card:not(.q-dialog .q-card,
.lnbits__dialog-card,
.q-dialog-plugin--dark) {
background-image: linear-gradient(135deg, rgb(43.9888888889, 64.2, 84.4111111111), rgba(25, 118, 210, 0.07));
}
body[data-theme=salvador].card-gradient.body--dark .q-drawer {
background-image: linear-gradient(165deg, rgb(42.2416666667, 61.65, 81.0583333333), rgba(25, 118, 210, 0.05));
}
body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
}
body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
:root {
@@ -455,3 +630,12 @@ video {
width: 90% !important;
}
}
.error-code {
font-size: clamp(15vh, 20vw, 30vh);
}
.error-message {
font-size: clamp(1.5rem, 3vw, 3.75rem);
font-weight: 300;
opacity: 0.4;
}
+76 -1
View File
@@ -74,6 +74,8 @@ window.localisation.en = {
update_name: 'Update name',
fiat_tracking: 'Fiat tracking',
fiat_providers: 'Fiat providers',
fiat_warning_bitcoin:
'Fiat providers can get twitchy about anything bitcoin, so avoid using word "bitcoin" in your memos!',
currency: 'Currency',
update_currency: 'Update currency',
press_to_claim: 'Press to claim bitcoin',
@@ -169,6 +171,7 @@ window.localisation.en = {
drop_db: 'Remove Data',
enable: 'Enable',
enabled: 'Enabled',
disabled: 'Disabled',
pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user',
disable: 'Disable',
@@ -176,6 +179,8 @@ window.localisation.en = {
installed: 'Installed',
activated: 'Activated',
deactivated: 'Deactivated',
activate: 'Activate',
deactivate: 'Deactivate',
release_notes: 'Release Notes',
activate_extension_details: 'Make extension available/unavailable for users',
featured: 'Featured',
@@ -184,7 +189,28 @@ window.localisation.en = {
only_admins_can_create_extensions:
'Only admin accounts can create extensions',
admin_only: 'Admin Only',
make_user_admin: 'Make User Admin',
revoke_admin: 'Revoke Admin',
new_version: 'New Version',
reviews_url: 'Reviews URL',
reviews_url_label: 'Reviews server URL',
reviews_url_hint:
'Full PaidReviews URL including the settings id (e.g. https://example.com/paidreviews/SETTINGS_ID)',
reviews_open: 'View reviews',
reviews_leave: 'Leave a review',
reviews_name: 'Your name',
reviews_comment: 'Your review',
reviews_rating: 'Rating',
reviews_submit: 'Submit review',
reviews_loading: 'Loading reviews...',
reviews_refresh: 'Refresh reviews',
reviews_error_load: 'Could not load reviews',
reviews_url_not_configured: 'Reviews URL not configured',
reviews_pay_invoice: 'Pay invoice',
reviews_invoice_paid: 'Invoice paid',
reviews_invoice_title: 'Pay this invoice to submit your review',
reviews_count: 'Reviews',
no_reviews: 'No reviews yet',
extension_has_free_release: 'Has free releases',
extension_has_paid_release: 'Has paid releases',
extension_depends_on: 'Depends on:',
@@ -237,6 +263,7 @@ window.localisation.en = {
webhook_url: 'Webhook URL',
webhook_url_hint:
'Webhook URL to send the payment details to. It will be called when the payment is completed.',
copy_webhook_url: 'Copy webhook URL',
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
@@ -258,7 +285,7 @@ window.localisation.en = {
'Nip5 identifier to send notifications to',
notifications_nostr_identifiers: 'Nostr Identifiers',
notifications_nostr_identifiers_desc:
'List of identifiers to send notifications to',
'List of identifiers to send notifications to.',
notifications_telegram_config: 'Telegram Configuration',
notifications_enable_telegram: 'Enable Telegram',
@@ -428,6 +455,7 @@ window.localisation.en = {
look_and_feel: 'Look and Feel',
endpoint: 'Endpoint',
api: 'API',
api_stripe: 'API',
api_token: 'API Token',
api_tokens: 'API Tokens',
access_control_list: 'Access Control List',
@@ -437,6 +465,12 @@ window.localisation.en = {
api_token_id: 'Token Id',
toggle_gradient: 'Toggle Gradient',
gradient_background: 'Gradient Background',
rounded_ui: 'Rounded Cards & Buttons',
toggle_rounded_ui: 'Toggle rounded corners for cards and buttons',
card_gradient: 'Card Gradient',
toggle_card_gradient: 'Toggle gradient on cards',
card_shadow: 'Card Shadow',
toggle_card_shadow: 'Toggle shadow on cards',
language: 'Language',
assets: 'Assets',
max_asset_size_mb: 'Max Asset Size (MB)',
@@ -495,6 +529,7 @@ window.localisation.en = {
hash: 'Hash: ',
welcome_lnbits: 'Welcome to LNbits',
setup_su_account: 'Set up the Superuser account below.',
first_install_token: 'First Install Token',
create_ticker_converter: 'Create Currency Ticker Converter',
enable_audit: 'Enable Audit',
recommended: 'Recommended',
@@ -574,6 +609,9 @@ window.localisation.en = {
authentication: 'Authentication',
auth_token_expiry_label: 'Token expire minutes',
auth_token_expiry_hint: 'Time in minutes until the token expires',
auth_authentication_cache_label: 'Cache time (minutes)',
auth_authentication_cache_hint:
'Time in minutes to cache successful authentication (0 to disable)',
auth_allowed_methods_label: 'Allowed authorization methods',
auth_allowed_methods_hint: 'Select authorization methods',
auth_nostr_label: 'Nostr Request URL',
@@ -632,12 +670,23 @@ window.localisation.en = {
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
ui_default_wallet_name: 'Default Wallet Name',
ui_default_theme: 'Default Theme',
wallet_featured_button_label: 'Wallet Featured Button Label',
wallet_featured_button_label_hint:
'Show featured button on the wallet homepage',
wallet_featured_button_url: 'Featured Button URL',
wallet_featured_button_url_hint:
'On click the button will open this URL. Leave empty to hide the button.',
wallet_featured_button_icon: 'Featured Button Icon',
wallet_featured_button_icon_hint:
'Icon shown on the featured button (check quasar icons)',
lnbits_wallet: 'LNbits wallet',
denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token',
denomination_error: 'Denomination must be 3 characters, or `sats`',
ui_qr_code_logo: 'QR Code/Favicon Logo',
ui_qr_code_logo_hint: 'QR code and favicon logo url',
ui_apple_touch_icon: 'Apple Touch Icon',
ui_apple_touch_icon_hint: 'Apple touch icon url',
ui_custom_image: 'Custom Image',
ui_custom_image_label: 'URL to custom image',
ui_custom_image_hint: 'Image showed at homepage/login',
@@ -666,7 +715,28 @@ window.localisation.en = {
allowed_users_label: 'User ID',
allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page',
require_user_activation: 'Require user activation',
require_user_activation_desc:
'New users will be activated only after they pass one of the confirmation methods. Admins can activate users manually from the admin panel.',
reusable_activation_code: 'Reusable activation code',
reusable_activation_code_label: 'Reusable activation code',
reusable_activation_code_hint:
'This activation code can be used multiple times by different users.',
one_time_activation_code: 'One-time activation codes',
one_time_activation_code_label: 'Add activation code',
one_time_activation_code_hint:
'List of one-time activation codes. Each code can be used only once, then will be reomved from the list.',
invitation_code: 'Invitation Code',
invitation_code_hint: 'The invitation code that you have received.',
email: 'Email',
email_confirmation_hint: 'Email address to send the confirmation code to.',
nostr_identifier: 'Nostr Identifier',
nostr_identifier_hint:
'Nostr nip5 identifier or <npub> to send the confirmation code to.',
new_user_not_allowed: 'Registration is disabled.',
start_user_impersonation: 'Impersonate this user',
stop_user_impersonation: 'Stop User Impersonation',
components: 'Components',
long_running_endpoints: 'Top 5 Long Running Endpoints',
http_request_methods: 'HTTP Request Methods',
@@ -699,10 +769,15 @@ window.localisation.en = {
filter_labels: 'Filter labels',
filter_date: 'Filter by date',
websocket_example: 'Websocket example',
client_id: 'Client ID',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.',
webhook_id: 'Webhook ID',
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful',
+5
View File
@@ -628,10 +628,15 @@ window.localisation.fi = {
filter_payments: 'Suodata maksuja',
filter_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket example',
client_id: 'Client ID',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.',
webhook_id: 'Webhook ID',
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
+34 -12
View File
@@ -66,7 +66,7 @@ window._lnbitsApi = {
name: name
})
},
register(username, email, password, password_repeat) {
register(username, email, password, password_repeat, invitation_code) {
return axios({
method: 'POST',
url: '/api/v1/auth/register',
@@ -74,7 +74,8 @@ window._lnbitsApi = {
username,
email,
password,
password_repeat
password_repeat,
invitation_code
}
})
},
@@ -89,6 +90,12 @@ window._lnbitsApi = {
}
})
},
getAuthUser() {
return axios({
method: 'GET',
url: '/api/v1/auth'
})
},
login(username, password) {
return axios({
method: 'POST',
@@ -117,19 +124,30 @@ window._lnbitsApi = {
url: '/api/v1/auth/logout'
})
},
impersonateUser(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/impersonate',
data: {usr}
})
},
stopImpersonation() {
return axios({
method: 'DELETE',
url: '/api/v1/auth/impersonate'
})
},
getAuthenticatedUser() {
return this.request('get', '/api/v1/auth')
},
getWallet(wallet) {
return this.request('get', '/api/v1/wallet', wallet.inkey)
},
createWallet(wallet, name, walletType, ops = {}) {
return this.request('post', '/api/v1/wallet', wallet.adminkey, {
createWallet(name, walletType, opts = {}) {
return this.request('post', '/api/v1/wallet', null, {
name: name,
wallet_type: walletType,
...ops
}).then(res => {
window.location = '/wallet?wal=' + res.data.id
...opts
})
},
updateWallet(name, wallet) {
@@ -137,6 +155,9 @@ window._lnbitsApi = {
name: name
})
},
updateUiCustomization(data = {}) {
return this.request('patch', '/api/v1/auth/ui', null, data)
},
resetWalletKeys(wallet) {
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
res => {
@@ -145,11 +166,7 @@ window._lnbitsApi = {
)
},
deleteWallet(wallet) {
return this.request('delete', `/api/v1/wallet/${wallet.id}`).then(_ => {
let url = new URL(window.location.href)
url.searchParams.delete('wal')
window.location = url
})
return this.request('delete', `/api/v1/wallet/${wallet.id}`)
},
getPayments(wallet, params) {
return this.request(
@@ -171,5 +188,10 @@ window._lnbitsApi = {
return this.request('GET', '/api/v1/currencies').then(response => {
return ['sats', ...response.data]
})
},
getDefaultSetting(fieldName) {
return LNbits.api
.request('GET', `/admin/api/v1/settings/default?field_name=${fieldName}`)
.catch(LNbits.utils.notifyApiError)
}
}
+4 -4
View File
@@ -6,13 +6,16 @@ window.LNbits = {
user(data) {
const obj = {
id: data.id,
username: data.username,
admin: data.admin,
email: data.email,
extensions: data.extensions,
wallets: data.wallets,
fiat_providers: data.fiat_providers || [],
super_user: data.super_user,
extra: data.extra ?? {}
extra: data.extra ?? {},
hasPassword: data.has_password ?? false,
uiCustomization: data.ui_customization || {}
}
const mapWallet = this.wallet
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
@@ -50,9 +53,6 @@ window.LNbits = {
}
newWallet.msat = data.balance_msat
newWallet.sat = Math.floor(data.balance_msat / 1000)
newWallet.fsat = new Intl.NumberFormat(window.i18n.global.locale).format(
newWallet.sat
)
if (newWallet.walletType === 'lightning-shared') {
const perms = newWallet.sharePermissions
newWallet.canReceivePayments = perms.includes('receive-payments')
+53 -72
View File
@@ -3,44 +3,38 @@ window.app.component(QrcodeVue)
window.app.component('lnbits-extension-rating', {
template: '#lnbits-extension-rating',
name: 'lnbits-extension-rating',
props: ['rating']
})
window.app.component('lnbits-fsat', {
template: '<span>{{ fsat }}</span>',
props: {
amount: {
rating: {
type: Number,
default: 0
},
count: {
type: Number,
default: null
},
clickable: {
type: Boolean,
default: false
}
},
computed: {
fsat() {
return LNbits.utils.formatSat(this.amount)
displayRating() {
return Math.round((this.rating || 0) * 2) / 2
},
hasData() {
return this.count !== null && this.count !== undefined
}
},
methods: {
handleClick() {
if (!this.clickable) return
this.$emit('click')
}
}
})
window.app.component('lnbits-manage', {
mixins: [window.windowMixin],
template: '#lnbits-manage',
computed: {
showAdmin() {
return this.LNBITS_ADMIN_UI
},
showUsers() {
return this.LNBITS_ADMIN_UI
},
showNode() {
return this.LNBITS_NODE_UI
},
showAudit() {
return this.LNBITS_AUDIT_ENABLED
},
showExtensions() {
return this.LNBITS_EXTENSIONS_DEACTIVATE_ALL === false
}
},
methods: {
isActive(path) {
return window.location.pathname === path
@@ -54,15 +48,8 @@ window.app.component('lnbits-manage', {
})
window.app.component('lnbits-payment-details', {
mixins: [window.windowMixin],
template: '#lnbits-payment-details',
props: ['payment'],
mixins: [window.windowMixin],
data() {
return {
LNBITS_DENOMINATION: LNBITS_DENOMINATION
}
},
computed: {
hasPreimage() {
return (
@@ -106,7 +93,6 @@ window.app.component('lnbits-payment-details', {
})
window.app.component('lnbits-lnurlpay-success-action', {
mixins: [window.windowMixin],
template: '#lnbits-lnurlpay-success-action',
props: ['payment', 'success_action'],
data() {
@@ -116,17 +102,16 @@ window.app.component('lnbits-lnurlpay-success-action', {
},
mounted() {
if (this.success_action.tag !== 'aes') return null
decryptLnurlPayAES(this.success_action, this.payment.preimage).then(
value => {
this.utils
.decryptLnurlPayAES(this.success_action, this.payment.preimage)
.then(value => {
this.decryptedValue = value
}
)
})
}
})
window.app.component('lnbits-notifications-btn', {
template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
props: ['pubkey'],
data() {
return {
@@ -297,7 +282,6 @@ window.app.component('lnbits-notifications-btn', {
window.app.component('lnbits-dynamic-fields', {
template: '#lnbits-dynamic-fields',
mixins: [window.windowMixin],
props: ['options', 'modelValue'],
data() {
return {
@@ -330,7 +314,6 @@ window.app.component('lnbits-dynamic-fields', {
window.app.component('lnbits-dynamic-chips', {
template: '#lnbits-dynamic-chips',
mixins: [window.windowMixin],
props: ['modelValue'],
data() {
return {
@@ -361,14 +344,10 @@ window.app.component('lnbits-dynamic-chips', {
window.app.component('lnbits-update-balance', {
template: '#lnbits-update-balance',
mixins: [window.windowMixin],
props: ['wallet_id', 'small_btn'],
computed: {
denomination() {
return LNBITS_DENOMINATION
},
admin() {
return user.super_user
return this.g.user?.super_user === true
}
},
data() {
@@ -403,7 +382,6 @@ window.app.component('lnbits-update-balance', {
window.app.component('user-id-only', {
template: '#user-id-only',
mixins: [window.windowMixin],
props: {
allowed_new_users: Boolean,
authAction: String,
@@ -447,7 +425,6 @@ window.app.component('user-id-only', {
window.app.component('username-password', {
template: '#username-password',
mixins: [window.windowMixin],
props: {
allowed_new_users: Boolean,
authMethods: Array,
@@ -455,8 +432,10 @@ window.app.component('username-password', {
username: String,
password_1: String,
password_2: String,
invitationCode: String,
resetKey: String
},
data() {
return {
oauth: [
@@ -469,8 +448,10 @@ window.app.component('username-password', {
password: this.password_1,
passwordRepeat: this.password_2,
reset_key: this.resetKey,
keycloakOrg: LNBITS_AUTH_KEYCLOAK_ORG || 'Keycloak',
keycloakIcon: LNBITS_AUTH_KEYCLOAK_ICON
confirmationMethod: 'code',
confirmationEmail: '',
confirmationCode: this.invitationCode || '',
showConfirmationCode: false
}
},
methods: {
@@ -483,6 +464,7 @@ window.app.component('username-password', {
this.$emit('update:userName', this.username)
this.$emit('update:password_1', this.password)
this.$emit('update:password_2', this.passwordRepeat)
this.$emit('update:invitationCode', this.confirmationCode)
this.$emit('register')
},
reset() {
@@ -574,6 +556,25 @@ window.app.component('username-password', {
computed: {
showOauth() {
return this.oauth.some(m => this.authMethods.includes(m))
},
disableRegister() {
const usernameOK = !!this.username
const passwordOK = !!this.password && this.password.length >= 8
const passwordsMatch = this.password === this.passwordRepeat
const codeOk =
this.confirmationMethodsCount === 0 ||
this.confirmationMethod !== 'code' ||
this.confirmationCode.length > 0
return !usernameOK || !passwordOK || !passwordsMatch || !codeOk
},
confirmationMethodsCount() {
const methods = [
this.g.settings.userActivationByEmail,
this.g.settings.userActivationByPayment,
this.g.settings.userActivationByInvitationCode
]
return methods.filter(Boolean).length
}
},
created() {}
@@ -685,7 +686,6 @@ window.app.component('lnbits-stat', {
window.app.component('lnbits-node-qrcode', {
props: ['info'],
mixins: [window.windowMixin],
template: `
<q-card class="my-card">
<q-card-section>
@@ -708,7 +708,7 @@ window.app.component('lnbits-node-qrcode', {
dense
unelevated
size="md"
@click="copyText(info.id)"
@click="utils.copyText(info.id)"
>Public Key<q-tooltip> Click to copy </q-tooltip>
</q-btn>
</q-card-actions>
@@ -757,24 +757,6 @@ window.app.component('lnbits-channel-balance', {
`
})
window.app.component('lnbits-date', {
props: ['ts'],
computed: {
date() {
return LNbits.utils.formatDate(this.ts)
},
dateFrom() {
return moment.utc(this.date).local().fromNow()
}
},
template: `
<div>
<q-tooltip>{{ this.date }}</q-tooltip>
{{ this.dateFrom }}
</div>
`
})
window.app.component('lnbits-node-info', {
props: ['info'],
data() {
@@ -782,7 +764,6 @@ window.app.component('lnbits-node-info', {
showDialog: false
}
},
mixins: [window.windowMixin],
methods: {
shortenNodeId(nodeId) {
return nodeId
@@ -807,7 +788,7 @@ window.app.component('lnbits-node-info', {
flat
dense
icon='content_paste'
@click='copyText(info.id)'
@click='utils.copyText(info.id)'
></q-btn>
<q-btn
size='xs'
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-assets-config', {
props: ['form-data'],
template: '#lnbits-admin-assets-config',
mixins: [window.windowMixin],
data() {
return {
newAllowedAssetMimeType: '',
@@ -17,6 +16,7 @@ window.app.component('lnbits-admin-assets-config', {
this.newAllowedAssetMimeType
)
this.newAllowedAssetMimeType = ''
this.formData.touch = null
}
},
removeAllowedAssetMimeType(type) {
@@ -24,18 +24,21 @@ window.app.component('lnbits-admin-assets-config', {
if (index !== -1) {
this.formData.lnbits_assets_allowed_mime_types.splice(index, 1)
}
this.formData.touch = null
},
addNewNoLimitUser() {
if (this.newNoLimitUser) {
this.removeNoLimitUser(this.newNoLimitUser)
this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser)
this.newNoLimitUser = ''
this.formData.touch = null
}
},
removeNoLimitUser(user) {
if (user) {
this.formData.lnbits_assets_no_limit_users =
this.formData.lnbits_assets_no_limit_users.filter(u => u !== user)
this.formData.touch = null
}
}
}
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-audit', {
props: ['form-data'],
template: '#lnbits-admin-audit',
mixins: [window.windowMixin],
data() {
return {
formAddIncludePath: '',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-exchange-providers', {
props: ['form-data'],
template: '#lnbits-admin-exchange-providers',
mixins: [window.windowMixin],
data() {
return {
exchangeData: {
@@ -70,6 +69,11 @@ window.app.component('lnbits-admin-exchange-providers', {
}
},
methods: {
getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => {
this.formData[fieldName] = response.data.default_value
})
},
getExchangeRateHistory() {
LNbits.api
.request('GET', '/api/v1/rate/history', this.g.user.wallets[0].inkey)
@@ -124,7 +128,7 @@ window.app.component('lnbits-admin-exchange-providers', {
},
initExchangeChart(data) {
const xValues = data.map(d =>
Quasar.date.formatDate(new Date(d.timestamp * 1000), 'HH:mm')
this.utils.formatTimestamp(d.timestamp, 'HH:mm')
)
const exchanges = [
...this.formData.lnbits_exchange_rate_providers,
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-extensions', {
props: ['form-data'],
template: '#lnbits-admin-extensions',
mixins: [window.windowMixin],
data() {
return {
formAddExtensionsManifest: ''
@@ -1,14 +1,82 @@
window.app.component('lnbits-admin-fiat-providers', {
props: ['form-data'],
template: '#lnbits-admin-fiat-providers',
mixins: [window.windowMixin],
data() {
return {
formAddStripeUser: '',
formAddPaypalUser: '',
hideInputToggle: true
}
},
computed: {
stripeWebhookUrl() {
return (
this.formData?.stripe_payment_webhook_url ||
this.calculateWebhookUrl('stripe')
)
},
paypalWebhookUrl() {
return (
this.formData?.paypal_payment_webhook_url ||
this.calculateWebhookUrl('paypal')
)
}
},
watch: {
formData: {
handler() {
this.syncWebhookUrls()
},
immediate: true
}
},
methods: {
basePathFromLocation() {
if (typeof window === 'undefined') {
return ''
}
const normalizedPath = window.location.pathname.replace(/\/+$/, '')
const adminIndex = normalizedPath.lastIndexOf('/admin')
const basePath =
adminIndex >= 0
? normalizedPath.slice(0, adminIndex)
: normalizedPath || ''
return basePath || ''
},
calculateWebhookUrl(provider) {
if (typeof window === 'undefined') {
return ''
}
const basePath = this.basePathFromLocation()
const path = `${basePath}/api/v1/callback/${provider}`.replace(
/\/+/g,
'/'
)
const withLeadingSlash = path.startsWith('/') ? path : `/${path}`
return `${window.location.origin}${withLeadingSlash}`
},
syncWebhookUrls() {
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
},
maybeSetWebhookUrl(fieldName, provider) {
if (!this.formData) {
return
}
const calculated = this.calculateWebhookUrl(provider)
const current = this.formData[fieldName]
const hasPlaceholder =
!current || current.includes('your-lnbits-domain-here.com')
if (hasPlaceholder && calculated) {
this.formData[fieldName] = calculated
}
},
copyWebhookUrl(url) {
if (!url) {
return
}
this.copyText(url)
},
addStripeAllowedUser() {
const addUser = this.formAddStripeUser || ''
if (
@@ -26,6 +94,23 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.stripe_limits.allowed_users =
this.formData.stripe_limits.allowed_users.filter(u => u !== user)
},
addPaypalAllowedUser() {
const addUser = this.formAddPaypalUser || ''
if (
addUser.length &&
!this.formData.paypal_limits.allowed_users.includes(addUser)
) {
this.formData.paypal_limits.allowed_users = [
...this.formData.paypal_limits.allowed_users,
addUser
]
this.formAddPaypalUser = ''
}
},
removePaypalAllowedUser(user) {
this.formData.paypal_limits.allowed_users =
this.formData.paypal_limits.allowed_users.filter(u => u !== user)
},
checkFiatProvider(providerName) {
LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`)
@@ -1,6 +1,5 @@
window.app.component('lnbits-admin-funding-sources', {
template: '#lnbits-admin-funding-sources',
mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'],
methods: {
getFundingSourceLabel(item) {
@@ -89,8 +88,14 @@ window.app.component('lnbits-admin-funding-sources', {
lnd_rest_cert: 'Certificate',
lnd_rest_macaroon: 'Macaroon',
lnd_rest_macaroon_encrypted: 'Encrypted Macaroon',
lnd_rest_route_hints: 'Enable Route Hints',
lnd_rest_allow_self_payment: 'Allow Self Payment'
lnd_rest_route_hints: {
advanced: true,
label: 'Enable Route Hints'
},
lnd_rest_allow_self_payment: {
advanced: true,
label: 'Allow Self Payment'
}
}
],
[
@@ -160,16 +165,27 @@ window.app.component('lnbits-admin-funding-sources', {
'BoltzWallet',
'Boltz',
{
boltz_client_endpoint: 'Endpoint',
boltz_client_macaroon: 'Admin Macaroon path or hex',
boltz_client_cert: 'Certificate path or hex',
boltz_client_wallet: 'Wallet Name',
boltz_client_password: 'Wallet Password (can be empty)',
boltz_client_endpoint: {
label: 'Boltz client endpoint',
value: '127.0.0.1:9002'
},
boltz_client_macaroon: {
label: 'Admin Macaroon path or hex',
value: '/home/ubuntu/.boltz/macaroons/admin.macaroon'
},
boltz_client_cert: {
label: 'Certificate path or hex',
value: '/home/ubuntu/.boltz/tls.cert'
},
boltz_mnemonic: {
label: 'Liquid mnemonic (copy into greenwallet)',
readonly: true,
label: 'Liquid seed phrase',
hint: 'Boltz will fetch once connected, but you can change later (can be opened in a liquid wallet) ',
copy: true,
qrcode: true
},
boltz_client_password: {
label: 'Wallet Password (optional)',
advanced: true
}
}
],
@@ -212,6 +228,43 @@ window.app.component('lnbits-admin-funding-sources', {
spark_token: 'Token'
}
],
[
'SparkL2Wallet',
'Spark (L2)',
{
spark_l2_external_endpoint: {
label: 'External Sidecar Endpoint',
hint: 'If specified then this endpoint will be used instead of the internal sidecar. Make sure to also specify the API key if your sidecar requires authentication.',
value: ''
},
spark_l2_external_api_key: {
label: 'External Sidecar API Key',
hint: 'API key for authenticating with the external sidecar if it requires authentication.',
value: ''
},
spark_l2_network: {
label: 'Network',
value: 'MAINNET',
hint: 'The network to use for the Spark wallet.',
advanced: true
},
spark_l2_pay_wait_ms: {
label: 'Payment Wait Time (ms)',
hint: 'The time to wait for a payment to be processed before considering it failed.',
advanced: true
},
spark_l2_pay_poll_ms: {
label: 'Payment Poll Time (ms)',
hint: 'The time to wait between polling for payment status updates.',
advanced: true
},
spark_l2_stream_keepalive_ms: {
label: 'Stream Keepalive Time (ms)',
hint: 'The time to wait between sending keepalive messages to the Spark sidecar to keep the connection open.',
advanced: true
}
}
],
[
'NWCWallet',
'Nostr Wallet Connect',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding',
mixins: [window.windowMixin],
data() {
return {
auditData: []
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-notifications', {
props: ['form-data'],
template: '#lnbits-admin-notifications',
mixins: [window.windowMixin],
data() {
return {
nostrNotificationIdentifier: '',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-security', {
props: ['form-data'],
template: '#lnbits-admin-security',
mixins: [window.windowMixin],
data() {
return {
logs: [],
@@ -1,5 +1,4 @@
window.app.component('lnbits-admin-server', {
props: ['form-data'],
template: '#lnbits-admin-server',
mixins: [window.windowMixin]
template: '#lnbits-admin-server'
})
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-site-customisation', {
props: ['form-data'],
template: '#lnbits-admin-site-customisation',
mixins: [window.windowMixin],
data() {
return {
lnbits_theme_options: [
@@ -1,11 +1,12 @@
window.app.component('lnbits-admin-users', {
props: ['form-data'],
template: '#lnbits-admin-users',
mixins: [window.windowMixin],
data() {
return {
formAddUser: '',
formAddAdmin: ''
formAddAdmin: '',
formAddActivationCode: '',
showReusableActivationCode: false
}
},
methods: {
@@ -32,6 +33,24 @@ window.app.component('lnbits-admin-users', {
removeAdminUser(user) {
let admin_users = this.formData.lnbits_admin_users
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user)
},
addOneTimeActivationCode() {
const code = this.formAddActivationCode
const activationCodes =
this.formData.lnbits_register_one_time_activation_codes
if (code?.length && !activationCodes.includes(code)) {
this.formData.lnbits_register_one_time_activation_codes = [
...activationCodes,
code
]
this.formAddActivationCode = ''
}
},
removeOneTimeActivationCode(code) {
const codes = this.formData.lnbits_register_one_time_activation_codes
this.formData.lnbits_register_one_time_activation_codes = codes.filter(
u => u !== code
)
}
}
})
@@ -1,6 +1,5 @@
window.app.component('lnbits-disclaimer', {
template: '#lnbits-disclaimer',
mixins: [window.windowMixin],
computed: {
showDisclaimer() {
return !g.disclaimerShown && g.isUserAuthorized
+1 -2
View File
@@ -1,4 +1,3 @@
window.app.component('lnbits-drawer', {
template: '#lnbits-drawer',
mixins: [window.windowMixin]
template: '#lnbits-drawer'
})
@@ -0,0 +1,52 @@
window.app.component('lnbits-error', {
template: '#lnbits-error',
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
}
}
}
})
+3 -7
View File
@@ -1,15 +1,11 @@
window.app.component('lnbits-footer', {
template: '#lnbits-footer',
mixins: [window.windowMixin],
computed: {
version() {
return this.LNBITS_VERSION || 'unknown version'
},
title() {
return `${this.SITE_TITLE}, ${this.SITE_TAGLINE}`
return `${this.g.settings.siteTitle}, ${this.g.settings.siteTagline}`
},
showFooter() {
return this.LNBITS_SHOW_HOME_PAGE_ELEMENTS == true
version() {
return this.$t('lnbits_version') + ': ' + this.g.settings.version
}
}
})

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