Compare commits

...
197 Commits
Author SHA1 Message Date
dni ⚡ f091ca343a rc4 2024-10-14 13:14:55 +02:00
Vlad Stan 1874a10342 fix: init DB 2024-10-14 13:59:28 +03:00
dni ⚡ c77cb07915 remove update_payment_status/extra/details 2024-10-14 12:34:46 +02:00
dni ⚡ 62d9470010 add dbversion model 2024-10-14 11:19:14 +02:00
dni ⚡ 1c451674cf make get_user better 2024-10-14 10:30:44 +02:00
dni ⚡ 137e716bb8 clean init admin settings 2024-10-14 10:19:21 +02:00
dni ⚡ 75e07b21c7 vlad 2024-10-14 10:06:13 +02:00
dni ⚡ b72a81aa4c fix qrscanner 2024-10-11 13:10:55 +02:00
dni ⚡ 22a16d27f1 fix dynamic chips 2024-10-10 16:06:24 +02:00
dni ⚡ 8843726ae4 qrcode 2024-10-10 13:31:21 +02:00
dni ⚡ fa57b0de3f fix extra null 2024-10-10 13:20:05 +02:00
dni ⚡ 6989d9ab34 fix where 2024-10-10 13:05:56 +02:00
dni ⚡ 63d316381e rc3 2024-10-10 09:14:35 +02:00
Tiago Vasconcelosanddni ⚡ 0e9b1b44b2 added thi one also DNI
remove it if not needed
2024-10-10 09:09:10 +02:00
Tiago Vasconcelosanddni ⚡ 9fa0ef97c1 attempt to make chip component in settings dynamic fields
it's not updating data
2024-10-10 09:09:09 +02:00
Tiago Vasconcelosanddni ⚡ 2e8128b85d screwed up and made more css pushes to this branch 2024-10-10 09:09:07 +02:00
Tiago Vasconcelosanddni ⚡ fd0fa75e11 remove shadow on cards in dark theme 2024-10-10 09:09:06 +02:00
dni ⚡ ff12a76b42 fix dynamic fields and ext db migration 2024-10-10 09:09:05 +02:00
Vlad Stananddni ⚡ 8a023471a9 fix: component name 2024-10-10 09:09:02 +02:00
Vlad Stananddni ⚡ 0b7c33c060 chore: code format 2024-10-10 09:09:01 +02:00
Vlad Stananddni ⚡ 4d2438cf49 fix: payment extra 2024-10-10 09:09:00 +02:00
Vlad Stananddni ⚡ f590efd719 fix: payment extra 2024-10-10 09:08:58 +02:00
Vlad Stananddni ⚡ d5ea1af518 chore: code format 2024-10-10 09:08:57 +02:00
Vlad Stananddni ⚡ f227be2ea5 fix: payment extra and payment hash 2024-10-10 09:08:56 +02:00
Vlad Stananddni ⚡ 3ccdc3e249 chore: log message fix 2024-10-10 09:08:54 +02:00
dni ⚡ 7ca0a35b3a vue3 @input migration 2024-10-10 09:08:53 +02:00
dni ⚡ 0286906bd9 extension toggle vue3 2024-10-10 09:08:52 +02:00
dni ⚡ 63b499d2c9 payments 2024-10-10 09:08:51 +02:00
Vlad Stananddni ⚡ 620325b399 fix: lint 2024-10-10 09:08:49 +02:00
Vlad Stananddni ⚡ 3cfbae3b28 chore: code format 2024-10-10 09:08:48 +02:00
Vlad Stananddni ⚡ a94f42368d feat: add JSON custom serializer 2024-10-10 09:08:47 +02:00
Vlad Stananddni ⚡ 74b24eb609 fix: cast balance to int 2024-10-10 09:08:45 +02:00
Vlad Stananddni ⚡ c6ec329328 fix: time column 2024-10-10 09:08:44 +02:00
Vlad Stananddni ⚡ cd549315f1 fix: empty currency 2024-10-10 09:08:43 +02:00
dni ⚡ 28897ca120 fixup! 2024-10-10 09:08:42 +02:00
dni ⚡ e8d10abee3 add wallet balance 2024-10-10 09:08:40 +02:00
dni ⚡ 7715e9c343 use db url 2024-10-10 09:08:39 +02:00
dni ⚡ de1a780f6d escape user 2024-10-10 09:08:38 +02:00
Vlad Stananddni ⚡ e2eb07285f chore: remove verbose key names 2024-10-10 09:08:36 +02:00
Vlad Stananddni ⚡ 343e7847df chore: format 2024-10-10 09:08:35 +02:00
Vlad Stananddni ⚡ 5cb361fe43 refactor: make settings a fixture 2024-10-10 09:08:34 +02:00
Vlad Stananddni ⚡ 4906bdcc4e fix: settings 2024-10-10 09:08:33 +02:00
Vlad Stananddni ⚡ 8725e1a2b7 fix: api tests 2024-10-10 09:08:31 +02:00
Vlad Stananddni ⚡ b0e2861aef fix: add back create_user_account 2024-10-10 09:08:30 +02:00
Vlad Stananddni ⚡ 388f9946e3 fix: some tests 2024-10-10 09:08:29 +02:00
dni ⚡ ed81ca55d9 parse date from dict 2024-10-10 09:08:27 +02:00
dni ⚡ e217e58bb6 add migration for view 2024-10-10 09:08:26 +02:00
dni ⚡ 7843a00edf fixes 2024-10-10 09:08:25 +02:00
dni ⚡ 7f3abcbd07 fix 2024-10-10 09:08:24 +02:00
Vlad Stananddni ⚡ f54c78a58d fix: account insert 2024-10-10 09:08:22 +02:00
dni ⚡ f923ecdefd fixup! 2024-10-10 09:08:20 +02:00
dni ⚡ cf03d94d4d fixup! 2024-10-10 09:08:19 +02:00
dni ⚡ 5f0068bc6d fixup! 2024-10-10 09:08:17 +02:00
dni ⚡ f85fb96063 fixup migration 2024-10-10 09:08:15 +02:00
dni ⚡ 84d9551706 fixup! 2024-10-10 09:08:13 +02:00
dni ⚡ 9ae475d4a0 fixup! 2024-10-10 09:08:12 +02:00
dni ⚡ 69e67d7d64 fixup! 2024-10-10 09:08:11 +02:00
dni ⚡ 382154b04a fix some tests 2024-10-10 09:08:10 +02:00
dni ⚡ f06b29dd82 fixup! 2024-10-10 09:08:08 +02:00
dni ⚡ 6e1b746056 fixup! 2024-10-10 09:08:07 +02:00
dni ⚡ 0b9e328363 fixup! 2024-10-10 09:08:06 +02:00
dni ⚡ f341537de2 fixup! 2024-10-10 09:08:04 +02:00
dni ⚡ 3551ee14a4 backwards compatible 2024-10-10 09:08:02 +02:00
dni ⚡ 0779535a9c fix account overview 2024-10-10 09:08:01 +02:00
dni ⚡ b0701fdb65 fix error 2024-10-10 09:07:59 +02:00
dni ⚡ 955a345900 fixup! 2024-10-10 09:07:58 +02:00
dni ⚡ aa236d99ce fixup-qrcode 2024-10-10 09:07:57 +02:00
dni ⚡ 5acddb75f5 some wallet ui fixes 2024-10-10 09:07:56 +02:00
dni ⚡ 2a17d5a8ce fixup! 2024-10-10 09:07:54 +02:00
dni ⚡ 4d75545994 revert 2024-10-10 09:07:53 +02:00
dni ⚡ cc8776ac74 fixup! 2024-10-10 09:07:52 +02:00
dni ⚡ 933c494836 more fixes 2024-10-10 09:07:50 +02:00
dni ⚡ ba843b3df4 fixup! 2024-10-10 09:07:49 +02:00
dni ⚡ 03b1a4f729 extension manager 2024-10-10 09:07:48 +02:00
dni ⚡ 0c456941e5 last fixes 2024-10-10 09:07:47 +02:00
dni ⚡ 200f5c52a7 big fx part2 2024-10-10 09:07:45 +02:00
dni ⚡ 383f6ec721 fix part1 2024-10-10 09:07:44 +02:00
Vlad Stananddni ⚡ 2410c271a6 chore: code clean-up 2024-10-10 09:07:43 +02:00
dni ⚡ 85b41c4aab remove where from argument 2024-10-10 09:07:42 +02:00
dni ⚡ cfdd81d37e fixup! 2024-10-10 09:07:40 +02:00
dni ⚡ c0db3468af more docs 2024-10-10 09:07:39 +02:00
dni ⚡ e1fb82cea5 fixup! 2024-10-10 09:07:38 +02:00
dni ⚡ 06bdd61c8e fixup! 2024-10-10 09:07:36 +02:00
dni ⚡ d813544bf4 fixup! 2024-10-10 09:07:35 +02:00
dni ⚡ cae81b61a3 fixup! 2024-10-10 09:07:34 +02:00
dni ⚡ 219e8746c5 feat: add shortcuts for insert_query and update_query into Database
example: await db.insert("table_name", base_model)
2024-10-10 09:07:29 +02:00
jackstar12andGitHub 512c85592f fix(boltz): sanitize invoice description (#2731)
Boltz rejects nbsp char (produced by JS Intl.NumberFormat api), so simply replace it with normal space
2024-10-09 19:32:56 +02:00
jackstar12andGitHub 76b01695a0 fix(boltz): check for direct onchain payments (#2732) 2024-10-09 18:52:20 +02:00
jackstar12andGitHub d7065392d1 chore: add .idea folder to gitignore (#2730) 2024-10-09 19:31:50 +03:00
Vlad StanandGitHub a58deff70c feat: block pay invoice (#2727) 2024-10-01 17:17:42 +03:00
dni ⚡andGitHub e85a78854e feat: make LNBITS_ADMIN_UI the default (#2726)
* feat: make LNBITS_ADMIN_UI the default
* fix create fake admin
2024-10-01 11:36:22 +02:00
dni ⚡andGitHub a28e334c5f test: fix test warning (#2725) 2024-10-01 11:07:13 +02:00
a4c000d7dc feat: add password reset for usermanager (#2688)
* feat: add password reset for usermanager
- add a reset_key to account table
- add ?reset_key= GET arguments to index.html and show reset form if provided
- superuser can generate and copy reset url with key to share
future ideas:
- could add send forgot password email if user fill out email address
* feat: simplify reset key
* test: use reset key
* test: add more tests
* test: reset passwords do not match
* test: `reset_password_auth_threshold_expired`

---------

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

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

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

---------

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

---------

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

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

---------

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

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

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

I don't see why this exists

---------

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

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

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

Wrong string case search

Closes #1599

* optimize the code

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

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

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

* chore: bundle

* fixup!

---------

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

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

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

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

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

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

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

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

* feat: paid_invoices_stream

* feat: proper invoice and payment status check

* feat: authenticate over insecure channel aswell

* chore: lint

* docs: add more setup instructions

* chore: add `boltz_client_cert` in frontend

* feat: populate fee_msat in get_payment_status and get_invoice_status

* fixup!

* chore: bundle

* added boltz logo

* add BoltzWallet to __all__

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

* chore: update protos

* feat: pass description when creating swap

* fixup!

* chore: bundle

---------

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

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

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

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

* cancel pending_payments_lookup_task on cleanup

* Rename subscription_timeout_task to timeout_task

* ensure preimage is not None

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

* fetch account info when possible

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

* fundle

* make format

* fix formatting

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

* format

* fix lint

* format

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

* fix padding

* fix documentation for _verify_event method

* refactoring and fixes

* Split NWCWallet - NWCConnection

* refactor class methods into helpers

* update bundle

* format

* catch NWCError failure codes

* format and fix

* chore: bundle

* add example

* typos

---------

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

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

* vlad picks
* refactor get_placeholder

---------

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

---------

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

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

* chore: `make format`

* chore: comment

* Update lnbits/helpers.py

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

* Update lnbits/helpers.py

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

* chore: code format

---------

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

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

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

---------

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

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

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

* chore: make bundle

* Blink review 01 (#2575)

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

* feat: set default value for `blink_api_endpoint`

* fix: raise if HTTP call failed

* refactor: move private method to the bottom

* refactor: make `wallet_id` a property

* fix: key mapping for attribute

* chore: fix `mypy`

* chore: fix `make check`

* refactor: extract query strings

* refactor: extract `BlinkGrafqlQueries` class

* chore: code clean-up

* chore: add `try-catch`

* refactor: extract `tx_query`

* chore: format grapfhql queries

* fix: set funding source class

* chore: `make format`

* fix: test by following the other patterns

* Update docs/guide/wallets.md

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

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

* feat: add websocket connection to blink

* feat: close websocket on shutdown

* feat: add `blink_ws_endpoint` to the UI

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

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

* fix: remove test env file

---------

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

* no assert in api

---------

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

websocket weren't working with that conf

* cleanup

---------

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

* refactor: extract `create_user_account` in services

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

* feat: add `details_link` field for extension

* feat: show info icon if `details_link` present

* feat: add extension details endpoint

* feat: first details page

* feat: carousel working

* feat: full screen

* fix: layout

* fix: repo site

* fix: release icon

* fix: repo link

* feat: terms and conditions partial

* chore: fix typing

* fix: info icon layout

* chore: add try-catch

* feat: layout improvements

* feat: add video link

* fix: show terms and conditions

* chore: code format

* feat: add `details_link`

* fix: github release details

* feat: add close button

* chore: code clean-up

* chore: revert some changes

* feat: i18n

* chore: `make bundle`

* chore: make bundle

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

fixing bug in normalize_enpoints

* Update lnbits/wallets/base.py

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

---------

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

---------

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


---------

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

* Sort funding options select alphabetically

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

- Support description, restricted to 128 characters
- Support descriptionHash

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: Vic <vic@example.com>
2024-05-29 13:47:10 +03:00
170 changed files with 42259 additions and 15700 deletions
+1
View File
@@ -3,6 +3,7 @@ data
docker docker
docs docs
tests tests
node_modules
lnbits/static/css/* lnbits/static/css/*
lnbits/static/bundle.js lnbits/static/bundle.js
+28 -4
View File
@@ -9,8 +9,8 @@
# configurations defined in `ReadOnlySettings` will still be read from the environment variables. # configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them # The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI. # only through the Admin UI.
# Disable this to make LNbits use this config file again. # Disable this and clear `settings` table from database to make LNbits use this config file again.
LNBITS_ADMIN_UI=false LNBITS_ADMIN_UI=true
# Change theme # Change theme
LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TITLE="LNbits"
@@ -28,7 +28,7 @@ PORT=5000
###################################### ######################################
# which fundingsources are allowed in the admin ui # which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet" # LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -87,10 +87,21 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
ALBY_API_ENDPOINT=https://api.getalby.com/ ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits"
# ZBDWallet # ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/ ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN ZBD_API_KEY=ZBD_ACCESS_TOKEN
# BlinkWallet
BLINK_API_ENDPOINT=https://api.blink.sv/graphql
BLINK_WS_ENDPOINT=wss://ws.blink.sv/graphql
BLINK_TOKEN=BLINK_TOKEN
# PhoenixdWallet # PhoenixdWallet
PHOENIXD_API_ENDPOINT=http://localhost:9740/ PHOENIXD_API_ENDPOINT=http://localhost:9740/
PHOENIXD_API_PASSWORD=PHOENIXD_KEY PHOENIXD_API_PASSWORD=PHOENIXD_KEY
@@ -107,18 +118,29 @@ LNBITS_DENOMINATION=sats
ECLAIR_URL=http://127.0.0.1:8283 ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw ECLAIR_PASS=eclairpw
# NWCWalllet
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
# LnTipsWallet # LnTipsWallet
# Enter /api in LightningTipBot to get your key # Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips LNTIPS_API_ENDPOINT=https://ln.tips
# BreezSdkWallet
BREEZ_API_KEY=KEY
BREEZ_GREENLIGHT_SEED=SEED
# A Greenlight invite code or Greenlight partner certificate/key can be used
BREEZ_GREENLIGHT_INVITE_CODE=CODE
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
###################################### ######################################
####### Auth Configurations ########## ####### Auth Configurations ##########
###################################### ######################################
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value. # Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
AUTH_SECRET_KEY="" AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600 AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth # Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password" AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth # Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1" # OAUTHLIB_INSECURE_TRANSPORT="1"
@@ -162,6 +184,8 @@ LNBITS_ADMIN_USERS=""
# Extensions only admin can access # Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, admin" LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
# Extensions enabled by default when a user is created
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
# Start LNbits core only. The extensions are not loaded. # Start LNbits core only. The extensions are not loaded.
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true # LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
+4 -1
View File
@@ -46,7 +46,10 @@ runs:
- name: Install the project dependencies - name: Install the project dependencies
shell: bash shell: bash
run: poetry install run: |
poetry install
# needed for conv tests
poetry add psycopg2-binary
- name: Use Node.js ${{ inputs.node-version }} - name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }} if: ${{ (inputs.npm == 'true') }}
+3 -4
View File
@@ -28,11 +28,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: cachix/install-nix-action@v24 - uses: cachix/install-nix-action@v27
with: with:
nix_path: nixpkgs=channel:nixos-23.11 nix_path: nixpkgs=channel:nixos-24.05
- uses: cachix/cachix-action@v13 - uses: cachix/cachix-action@v15
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
with: with:
name: lnbits name: lnbits
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
+32
View File
@@ -0,0 +1,32 @@
name: release-rc
on:
push:
tags:
- "*-rc[0-9]"
permissions:
contents: write
jobs:
docker:
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
runs-on: ubuntu-latest
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
+4
View File
@@ -43,6 +43,10 @@ jobs:
pypi: pypi:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: 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 - uses: actions/checkout@v4
- name: Build and publish to pypi - name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15 uses: JRubics/poetry-publish@v1.15
+6 -2
View File
@@ -35,6 +35,7 @@ __bundle__
coverage.xml coverage.xml
node_modules node_modules
lnbits/static/bundle.js lnbits/static/bundle.js
lnbits/static/bundle-components.js
lnbits/static/bundle.css lnbits/static/bundle.css
lnbits/static/bundle.min.js.old lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old lnbits/static/bundle.min.css.old
@@ -49,8 +50,11 @@ fly.toml
lnbits-backup.zip lnbits-backup.zip
# Ignore extensions (post installable extension PR) # Ignore extensions (post installable extension PR)
extensions /lnbits/extensions
upgrades/ /upgrades/
# builded python package # builded python package
dist dist
# jetbrains
.idea
+1
View File
@@ -10,6 +10,7 @@
**/lnbits/static/vendor **/lnbits/static/vendor
**/lnbits/static/bundle.* **/lnbits/static/bundle.*
**/lnbits/static/bundle-components.*
**/lnbits/static/css/* **/lnbits/static/css/*
flake.lock flake.lock
+34 -9
View File
@@ -1,4 +1,4 @@
FROM python:3.10-slim-bookworm FROM python:3.10-slim-bookworm AS builder
RUN apt-get clean RUN apt-get clean
RUN apt-get update RUN apt-get update
@@ -7,19 +7,44 @@ RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml poetry.lock ./
RUN mkdir data
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
RUN poetry install --only main
FROM python:3.10-slim-bookworm
# needed for backups postgresql-client version 14 (pg_dump) # needed for backups postgresql-client version 14 (pg_dump)
RUN apt install -y postgresql-common ca-certificates RUN apt-get update && apt-get -y upgrade && \
RUN install -d /usr/share/postgresql-common/pgdg apt-get -y install gnupg2 curl lsb-release && \
RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list' curl -s https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
RUN apt-get update apt-get update && \
RUN apt-get install -y postgresql-client-14 apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app WORKDIR /app
COPY . . COPY . .
COPY --from=builder /app/.venv .venv
RUN mkdir data
RUN poetry install --only main RUN poetry install --only main
+12 -7
View File
@@ -38,25 +38,32 @@ checkeditorconfig:
dev: dev:
poetry run lnbits --reload poetry run lnbits --reload
docker:
docker build -t lnbits/lnbits .
test-wallets: test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
poetry run pytest tests/wallets poetry run pytest tests/wallets
test-unit: test-unit:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
poetry run pytest tests/unit poetry run pytest tests/unit
test-api: test-api:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
poetry run pytest tests/api poetry run pytest tests/api
test-regtest: test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
poetry run pytest tests/regtest poetry run pytest tests/regtest
@@ -80,6 +87,7 @@ migration:
poetry run python tools/conv.py poetry run python tools/conv.py
openapi: openapi:
LNBITS_ADMIN_UI=False \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@@ -99,24 +107,21 @@ sass:
bundle: bundle:
npm install npm install
npm run sass npm run bundle
npm run vendor_copy
npm run vendor_json
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
npm run vendor_bundle_css
npm run vendor_minify_css
npm run vendor_bundle_js
npm run vendor_minify_js
checkbundle: checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
make bundle make bundle
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1 diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1 diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
@echo "Bundle is OK" @echo "Bundle is OK"
rm lnbits/static/bundle.min.js.old rm lnbits/static/bundle.min.js.old
rm lnbits/static/bundle.min.css.old rm lnbits/static/bundle.min.css.old
rm lnbits/static/bundle-components.min.js.old
install-pre-commit-hook: install-pre-commit-hook:
@echo "Installing pre-commit hook to git" @echo "Installing pre-commit hook to git"
+14 -2
View File
@@ -11,13 +11,16 @@ Thanks for contributing :)
# Run # Run
This starts the lnbits uvicorn server Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install poetry and other dependencies.
Then you can start LNbits uvicorn server with:
```bash ```bash
poetry run lnbits poetry run lnbits
``` ```
This starts the lnbits uvicorn with hot reloading. Or you can use the following to start uvicorn with hot reloading enabled:
```bash ```bash
make dev make dev
@@ -25,6 +28,15 @@ make dev
poetry run lnbits --reload poetry run lnbits --reload
``` ```
You might need the following extra dependencies on clean installation of Debian:
```
sudo apt install nodejs
sudo apt install npm
npm install
sudo apt-get install autoconf libtool libpg-dev
```
# Precommit hooks # Precommit hooks
This ensures that all commits adhere to the formatting and linting rules. This ensures that all commits adhere to the formatting and linting rules.
+31 -20
View File
@@ -6,26 +6,21 @@ nav_order: 2
# Basic installation # Basic installation
You can choose between four package managers, `poetry` and `nix` The following sections explain how to install LNbits using varions package managers: `poetry`, `nix`, `Docker` and `Fly.io`.
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below). Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
## Option 1 (recommended): poetry ## Option 1 (recommended): Poetry
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX) It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
Make sure you have Python 3.9 or 3.10 installed.
### install python on ubuntu ### Verify Python version
```sh ```sh
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version python3 --version
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
``` ```
### install poetry ### Install Poetry
```sh ```sh
curl -sSL https://install.python-poetry.org | python3 - curl -sSL https://install.python-poetry.org | python3 -
@@ -38,13 +33,8 @@ git clone https://github.com/lnbits/lnbits.git
cd lnbits cd lnbits
git checkout main git checkout main
# Next command, you can exchange with python3.10 or newer versions.
# Identify your version with python3 --version and specify in the next line
# command is only needed when your default python is not ^3.9 or ^3.10
poetry env use python3.9
poetry install --only main poetry install --only main
mkdir data
cp .env.example .env cp .env.example .env
# set funding source amongst other options # set funding source amongst other options
nano .env nano .env
@@ -70,7 +60,19 @@ poetry install --only main
# Start LNbits with `poetry run lnbits` # Start LNbits with `poetry run lnbits`
``` ```
## Option 2: Nix ## Option 2: Install script (on Debian/Ubuntu)
```sh
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
chmod +x lnbits.sh &&
./lnbits.sh
```
Now visit `0.0.0.0:5000` to make a super-user account.
`export PATH="/home/$USER/.local/bin:$PATH"` then `./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
## Option 3: Nix
```sh ```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download) # Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
@@ -108,7 +110,7 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
``` ```
## Option 3: Docker ## Option 4: Docker
use latest version from docker hub use latest version from docker hub
@@ -130,7 +132,7 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
``` ```
## Option 4: Fly.io ## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use. Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
@@ -444,6 +446,15 @@ server {
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_request_headers on;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
listen [::]:443 ssl; listen [::]:443 ssl;
listen 443 ssl; listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem; ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem;
+41
View File
@@ -76,6 +76,15 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/ - `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey - `OPENNODE_KEY`: opennodeAdminApiKey
### Blink
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
- `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
- `BLINK_TOKEN`: BlinkToken
### Alby ### Alby
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
@@ -84,6 +93,18 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `ALBY_API_ENDPOINT`: https://api.getalby.com/ - `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken - `ALBY_ACCESS_TOKEN`: AlbyAccessToken
### Boltz
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: lnbits
### ZBD ### ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
@@ -101,6 +122,26 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/ - `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword - `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
### Breez SDK
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
- `BREEZ_API_KEY`: ...
- `BREEZ_GREENLIGHT_SEED`: ...
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
### Cliche Wallet ### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000 - `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
### Nostr Wallet Connect (NWC)
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
Generated
+16 -16
View File
@@ -5,11 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1694529238, "lastModified": 1710146030,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384", "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -26,11 +26,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1698974481, "lastModified": 1703863825,
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=", "narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nix-github-actions", "repo": "nix-github-actions",
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2", "rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -41,16 +41,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1702233072, "lastModified": 1723938990,
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=", "narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348", "rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nixos",
"ref": "nixos-23.11", "ref": "nixos-24.05",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@@ -66,11 +66,11 @@
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
}, },
"locked": { "locked": {
"lastModified": 1702334837, "lastModified": 1724134185,
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=", "narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "nix-community", "owner": "nix-community",
"repo": "poetry2nix", "repo": "poetry2nix",
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99", "rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -122,11 +122,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1699786194, "lastModified": 1719749022,
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=", "narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1", "rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github" "type": "github"
}, },
"original": { "original": {
+2 -11
View File
@@ -2,7 +2,7 @@
description = "LNbits, free and open-source Lightning wallet and accounts system"; description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11"; nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
poetry2nix = { poetry2nix = {
url = "github:nix-community/poetry2nix"; url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
@@ -30,19 +30,10 @@
meta.rev = self.dirtyRev or self.rev; meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName; meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: { overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
coincurve = prev.coincurve.override { preferWheel = true; };
protobuf = prev.protobuf.override { preferWheel = true; }; protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; }; ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; }; wallycore = prev.wallycore.override { preferWheel = true; };
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
pytest-md = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
types-mock = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
}); });
}; };
}); });
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Check install has not already run
if [ ! -d lnbits/data ]; then
# Update package list and install prerequisites non-interactively
sudo apt update -y
sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively
sudo add-apt-repository -y ppa:deadsnakes/ppa
# Install Python 3.9 and distutils non-interactively
sudo apt install -y python3.9 python3.9-distutils
# Install Poetry
curl -sSL https://install.python-poetry.org | python3.9 -
# Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
# Clone the LNbits repository
git clone https://github.com/lnbits/lnbits.git
if [ $? -ne 0 ]; then
echo "Failed to clone the repository ... FAIL"
exit 1
fi
# Ensure we are in the lnbits directory
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
git checkout main
# Make data folder
mkdir data
# Copy the .env.example to .env
cp .env.example .env
elif [ ! -d lnbits/wallets ]; then
# cd into lnbits
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
# Set path for running after install
export PATH="/home/$USER/.local/bin:$PATH"
# Install the dependencies using Poetry
poetry env use python3.9
poetry install --only main
# Set environment variables for LNbits
export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Run LNbits
poetry run lnbits
+37 -37
View File
@@ -6,7 +6,7 @@ import shutil
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, List, Optional from typing import Callable, Optional
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -17,10 +17,11 @@ from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import ( from lnbits.core.crud import (
get_dbversions, get_db_version,
get_installed_extensions, get_installed_extensions,
update_installed_extension_state, update_installed_extension_state,
) )
from lnbits.core.extensions.helpers import version_parse
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.core.tasks import ( # watchdog_task from lnbits.core.tasks import ( # watchdog_task
killswitch_task, killswitch_task,
@@ -44,14 +45,8 @@ from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases from .commands import migrate_databases
from .core import init_core_routers from .core import init_core_routers
from .core.db import core_app_extra from .core.db import core_app_extra
from .core.extensions.models import Extension, ExtensionMeta, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension
from .extension_manager import (
Extension,
InstallableExtension,
get_valid_extensions,
version_parse,
)
from .middleware import ( from .middleware import (
CustomGZipMiddleware, CustomGZipMiddleware,
ExtensionsRedirectMiddleware, ExtensionsRedirectMiddleware,
@@ -63,6 +58,7 @@ from .middleware import (
from .requestvars import g from .requestvars import g
from .tasks import ( from .tasks import (
check_pending_payments, check_pending_payments,
create_task,
internal_invoice_listener, internal_invoice_listener,
invoice_listener, invoice_listener,
) )
@@ -93,13 +89,8 @@ async def startup(app: FastAPI):
# register core routes # register core routes
init_core_routers(app) init_core_routers(app)
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
await check_installed_extensions(app)
register_all_ext_routes(app)
# initialize tasks # initialize tasks
register_async_tasks() register_async_tasks(app)
async def shutdown(): async def shutdown():
@@ -210,7 +201,7 @@ async def check_funding_source() -> None:
break break
retry_counter += 1 retry_counter += 1
sleep_time = 0.25 * (2**retry_counter) sleep_time = min(0.25 * (2**retry_counter), 60)
logger.warning( logger.warning(
f"Retrying connection to backend in {sleep_time} seconds... " f"Retrying connection to backend in {sleep_time} seconds... "
f"({retry_counter}/{max_retries})" f"({retry_counter}/{max_retries})"
@@ -247,6 +238,8 @@ async def check_installed_extensions(app: FastAPI):
) )
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
settings.deactivate_extension_paths(ext.id)
await update_installed_extension_state(ext_id=ext.id, active=False)
logger.warning( logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})" f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
) )
@@ -258,7 +251,7 @@ async def check_installed_extensions(app: FastAPI):
async def build_all_installed_extensions_list( async def build_all_installed_extensions_list(
include_deactivated: Optional[bool] = True, include_deactivated: Optional[bool] = True,
) -> List[InstallableExtension]: ) -> list[InstallableExtension]:
""" """
Returns a list of all the installed extensions plus the extensions that Returns a list of all the installed extensions plus the extensions that
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL). MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
@@ -278,8 +271,13 @@ async def build_all_installed_extensions_list(
release = next((e for e in ext_releases if e.is_version_compatible), None) release = next((e for e in ext_releases if e.is_version_compatible), None)
if release: if release:
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension( ext_info = InstallableExtension(
id=ext_id, name=ext_id, installed_release=release, icon=release.icon id=ext_id,
name=ext_id,
version=release.version,
icon=release.icon,
meta=ext_meta,
) )
installed_extensions.append(ext_info) installed_extensions.append(ext_info)
@@ -310,18 +308,16 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension): async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
await add_installed_extension(ext)
await update_installed_extension_state(ext_id=ext.id, active=True) await update_installed_extension_state(ext_id=ext.id, active=True)
extension = Extension.from_installable_ext(ext) extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension) register_ext_routes(app, extension)
current_version = (await get_dbversions()).get(ext.id, 0) current_version = await get_db_version(ext.id)
await migrate_extension_database(extension, current_version) await migrate_extension_database(ext, current_version)
# mount routes for the new version # mount routes for the new version
core_app_extra.register_new_ext_routes(extension) core_app_extra.register_new_ext_routes(extension)
ext.notify_upgrade(extension.upgrade_hash)
def register_custom_extensions_path(): def register_custom_extensions_path():
@@ -384,37 +380,41 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
) )
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"]) app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
if hasattr(ext_module, f"{ext.code}_redirect_paths"): ext_redirects = (
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths") getattr(ext_module, f"{ext.code}_redirect_paths")
settings.lnbits_extensions_redirects = [ if hasattr(ext_module, f"{ext.code}_redirect_paths")
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code else []
] )
for r in ext_redirects:
r["ext_id"] = ext.code
settings.lnbits_extensions_redirects.append(r)
logger.trace(f"adding route for extension {ext_module}") settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
logger.trace(f"Adding route for extension {ext_module}.")
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else "" prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
app.include_router(router=ext_route, prefix=prefix) app.include_router(router=ext_route, prefix=prefix)
def register_all_ext_routes(app: FastAPI): async def check_and_register_extensions(app: FastAPI):
for ext in get_valid_extensions(False): await check_installed_extensions(app)
for ext in Extension.get_valid_extensions(False):
try: try:
register_ext_routes(app, ext) register_ext_routes(app, ext)
except Exception as e: except Exception as exc:
logger.error(f"Could not load extension `{ext.code}`: {e!s}") logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
def register_async_tasks(): def register_async_tasks(app: FastAPI):
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
create_task(check_and_register_extensions(app))
create_permanent_task(check_pending_payments) create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener) create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener) create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever) create_permanent_task(cache.invalidate_forever)
# core invoice listener # core invoice listener
invoice_queue = asyncio.Queue(5) invoice_queue: asyncio.Queue = asyncio.Queue(5)
register_invoice_listener(invoice_queue, "core") register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue)) create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
+44 -99
View File
@@ -3,8 +3,7 @@ import importlib
import time import time
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple from typing import Optional
from urllib.parse import urlparse
import click import click
import httpx import httpx
@@ -12,7 +11,27 @@ from fastapi.exceptions import HTTPException
from loguru import logger from loguru import logger
from packaging import version from packaging import version
from lnbits.core.models import Payment, User from lnbits.core import db as core_db
from lnbits.core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_db_versions,
get_installed_extension,
get_installed_extensions,
get_payment,
get_payments,
remove_deleted_wallets,
update_payment,
)
from lnbits.core.extensions.models import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services import check_admin_settings from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import ( from lnbits.core.views.extension_api import (
api_install_extension, api_install_extension,
@@ -21,29 +40,6 @@ from lnbits.core.views.extension_api import (
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets.base import Wallet from lnbits.wallets.base import Wallet
from .core import db as core_db
from .core import migrations as core_migrations
from .core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_dbversions,
get_installed_extension,
get_installed_extensions,
get_payments,
remove_deleted_wallets,
update_payment_status,
)
from .core.helpers import migrate_extension_database, run_migration
from .db import COCKROACH, POSTGRES, SQLITE
from .extension_manager import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
get_valid_extensions,
)
def coro(f): def coro(f):
@wraps(f) @wraps(f)
@@ -122,54 +118,12 @@ def database_migrate():
loop.run_until_complete(migrate_databases()) loop.run_until_complete(migrate_databases())
async def db_migrate():
task = asyncio.create_task(migrate_databases())
await task
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
# todo: revisit, use installed extensions
for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
@db.command("versions") @db.command("versions")
@coro @coro
async def db_versions(): async def db_versions():
"""Show current database versions""" """Show current database versions"""
async with core_db.connect() as conn: async with core_db.connect() as conn:
click.echo(await get_dbversions(conn)) click.echo(await get_db_versions(conn))
@db.command("cleanup-wallets") @db.command("cleanup-wallets")
@@ -216,10 +170,13 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
@db.command("mark-payment-pending") @db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.") @click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro @coro
async def database_revert_payment(checking_id: str, pending: bool = True): async def database_revert_payment(checking_id: str):
"""Mark wallet as deleted""" """Mark payment as pending"""
async with core_db.connect() as conn: async with core_db.connect() as conn:
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn) payment = await get_payment(checking_id=checking_id, conn=conn)
payment.status = PaymentState.PENDING
await update_payment(payment, conn)
click.echo(f"Payment '{checking_id}' marked as pending.")
@db.command("cleanup-accounts") @db.command("cleanup-accounts")
@@ -276,7 +233,7 @@ async def check_invalid_payments(
click.echo("Funding source: " + str(funding_source)) click.echo("Funding source: " + str(funding_source))
# payments that are settled in the DB, but not at the Funding source level # payments that are settled in the DB, but not at the Funding source level
invalid_payments: List[Payment] = [] invalid_payments: list[Payment] = []
invalid_wallets = {} invalid_wallets = {}
for db_payment in settled_db_payments: for db_payment in settled_db_payments:
if verbose: if verbose:
@@ -313,12 +270,6 @@ async def check_invalid_payments(
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)])) click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
@extensions.command("list") @extensions.command("list")
@coro @coro
async def extensions_list(): async def extensions_list():
@@ -328,8 +279,10 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list(): for ext in await build_all_installed_extensions_list():
assert ext.installed_release, f"Extension {ext.id} has no installed_release" assert (
click.echo(f" - {ext.id} ({ext.installed_release.version})") ext.meta and ext.meta.installed_release
), f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
@extensions.command("update") @extensions.command("update")
@@ -378,7 +331,7 @@ async def extensions_update(
if extension and all_extensions: if extension and all_extensions:
click.echo("Only one of extension ID or the '--all' flag must be specified") click.echo("Only one of extension ID or the '--all' flag must be specified")
return return
if url and not _is_url(url): if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}") click.echo(f"Invalid '--url' option value: {url}")
return return
@@ -452,7 +405,7 @@ async def extensions_install(
): ):
"""Install a extension""" """Install a extension"""
click.echo(f"Installing {extension}... {repo_index}") click.echo(f"Installing {extension}... {repo_index}")
if url and not _is_url(url): if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}") click.echo(f"Invalid '--url' option value: {url}")
return return
@@ -480,7 +433,7 @@ async def extensions_uninstall(
"""Uninstall a extension""" """Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...") click.echo(f"Uninstalling '{extension}'...")
if url and not _is_url(url): if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}") click.echo(f"Invalid '--url' option value: {url}")
return return
@@ -512,7 +465,7 @@ async def install_extension(
source_repo: Optional[str] = None, source_repo: Optional[str] = None,
url: Optional[str] = None, url: Optional[str] = None,
admin_user: Optional[str] = None, admin_user: Optional[str] = None,
) -> Tuple[bool, str]: ) -> tuple[bool, str]:
try: try:
release = await _select_release(extension, repo_index, source_repo) release = await _select_release(extension, repo_index, source_repo)
if not release: if not release:
@@ -541,7 +494,7 @@ async def update_extension(
source_repo: Optional[str] = None, source_repo: Optional[str] = None,
url: Optional[str] = None, url: Optional[str] = None,
admin_user: Optional[str] = None, admin_user: Optional[str] = None,
) -> Tuple[bool, str]: ) -> tuple[bool, str]:
try: try:
click.echo(f"Updating '{extension}' extension.") click.echo(f"Updating '{extension}' extension.")
installed_ext = await get_installed_extension(extension) installed_ext = await get_installed_extension(extension)
@@ -554,7 +507,7 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.") click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
assert ( assert (
installed_ext.installed_release installed_ext.meta and installed_ext.meta.installed_release
), "Cannot find previously installed release. Please uninstall first." ), "Cannot find previously installed release. Please uninstall first."
release = await _select_release(extension, repo_index, source_repo) release = await _select_release(extension, repo_index, source_repo)
@@ -562,7 +515,7 @@ async def update_extension(
return False, "No release selected." return False, "No release selected."
if ( if (
release.version == installed_ext.installed_version release.version == installed_ext.installed_version
and release.source_repo == installed_ext.installed_release.source_repo and release.source_repo == installed_ext.meta.installed_release.source_repo
): ):
click.echo(f"Extension '{extension}' already up to date.") click.echo(f"Extension '{extension}' already up to date.")
return False, "Already up to date" return False, "Already up to date"
@@ -661,7 +614,7 @@ async def _call_install_extension(
) )
resp.raise_for_status() resp.raise_for_status()
else: else:
await api_install_extension(data, User(id="mock_id")) await api_install_extension(data)
async def _call_uninstall_extension( async def _call_uninstall_extension(
@@ -675,7 +628,7 @@ async def _call_uninstall_extension(
) )
resp.raise_for_status() resp.raise_for_status()
else: else:
await api_uninstall_extension(extension, User(id="mock_id")) await api_uninstall_extension(extension)
async def _can_run_operation(url) -> bool: async def _can_run_operation(url) -> bool:
@@ -694,7 +647,7 @@ async def _can_run_operation(url) -> bool:
elif url: elif url:
click.echo( click.echo(
"The option '--url' has been provided," "The option '--url' has been provided,"
+ f" but no server found runnint at '{url}'" f" but no server found running at '{url}'"
) )
return False return False
@@ -709,11 +662,3 @@ async def _is_lnbits_started(url: Optional[str]):
return True return True
except Exception: except Exception:
return False return False
def _is_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+3
View File
@@ -38,3 +38,6 @@ def init_core_routers(app: FastAPI):
app.include_router(tinyurl_router) app.include_router(tinyurl_router)
app.include_router(webpush_router) app.include_router(webpush_router)
app.include_router(users_router) app.include_router(users_router)
__all__ = ["core_app", "core_app_extra", "db"]
+383 -708
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
import asyncio
import importlib
from loguru import logger
from lnbits.core import core_app_extra
from lnbits.core.crud import (
create_installed_extension,
delete_installed_extension,
get_db_version,
get_installed_extension,
update_installed_extension_state,
)
from lnbits.core.helpers import migrate_extension_database
from lnbits.settings import settings
from .models import Extension, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_id = ext_info.id
extension = Extension.from_installable_ext(ext_info)
installed_ext = await get_installed_extension(ext_id)
if installed_ext:
ext_info.meta = installed_ext.meta
await ext_info.download_archive()
ext_info.extract_archive()
db_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, db_version)
await create_installed_extension(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(ext_id)
return extension
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
settings.deactivate_extension_paths(ext_id)
extension = await get_installed_extension(ext_id)
if extension:
extension.clean_extension_files()
await delete_installed_extension(ext_id=ext_id)
async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
async def stop_extension_background_work(ext_id: str) -> bool:
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function.
"""
upgrade_hash = settings.lnbits_upgraded_extensions.get(ext_id, "")
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, f"No stop function found for '{ext.module_name}'."
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
if asyncio.iscoroutinefunction(stop_fn):
await stop_fn()
else:
stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
+56
View File
@@ -0,0 +1,56 @@
import hashlib
from typing import Any, Optional
from urllib import request
import httpx
from loguru import logger
from packaging import version
from lnbits.settings import settings
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
return version.parse(v)
except Exception:
return version.parse("0.0.0")
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
def file_hash(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, "rb", buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import json import json
@@ -6,16 +8,22 @@ import shutil
import sys import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, List, NamedTuple, Optional, Tuple from typing import Any, NamedTuple, Optional
from urllib import request
import httpx import httpx
from loguru import logger from loguru import logger
from packaging import version
from pydantic import BaseModel from pydantic import BaseModel
from lnbits.settings import settings from lnbits.settings import settings
from .helpers import (
download_url,
file_hash,
github_api_get,
icon_to_github_url,
version_parse,
)
class ExplicitRelease(BaseModel): class ExplicitRelease(BaseModel):
id: str id: str
@@ -23,7 +31,7 @@ class ExplicitRelease(BaseModel):
version: str version: str
archive: str archive: str
hash: str hash: str
dependencies: List[str] = [] dependencies: list[str] = []
repo: Optional[str] repo: Optional[str]
icon: Optional[str] icon: Optional[str]
short_description: Optional[str] short_description: Optional[str]
@@ -32,6 +40,7 @@ class ExplicitRelease(BaseModel):
warning: Optional[str] warning: Optional[str]
info_notification: Optional[str] info_notification: Optional[str]
critical_notification: Optional[str] critical_notification: Optional[str]
details_link: Optional[str]
pay_link: Optional[str] pay_link: Optional[str]
def is_version_compatible(self): def is_version_compatible(self):
@@ -47,9 +56,9 @@ class GitHubRelease(BaseModel):
class Manifest(BaseModel): class Manifest(BaseModel):
featured: List[str] = [] featured: list[str] = []
extensions: List["ExplicitRelease"] = [] extensions: list[ExplicitRelease] = []
repos: List["GitHubRelease"] = [] repos: list[GitHubRelease] = []
class GitHubRepoRelease(BaseModel): class GitHubRepoRelease(BaseModel):
@@ -58,6 +67,9 @@ class GitHubRepoRelease(BaseModel):
zipball_url: str zipball_url: str
html_url: str html_url: str
def details_link(self, source_repo: str) -> str:
return f"https://raw.githubusercontent.com/{source_repo}/{self.tag_name}/config.json"
class GitHubRepo(BaseModel): class GitHubRepo(BaseModel):
stargazers_count: str stargazers_count: str
@@ -77,6 +89,17 @@ class ExtensionConfig(BaseModel):
return True return True
return version_parse(self.min_lnbits_version) <= version_parse(settings.version) return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
class ReleasePaymentInfo(BaseModel): class ReleasePaymentInfo(BaseModel):
amount: Optional[int] = None amount: Optional[int] = None
@@ -86,8 +109,8 @@ class ReleasePaymentInfo(BaseModel):
class PayToEnableInfo(BaseModel): class PayToEnableInfo(BaseModel):
required: Optional[bool] = False amount: int
amount: Optional[int] = None required: bool = False
wallet: Optional[str] = None wallet: Optional[str] = None
@@ -97,6 +120,7 @@ class UserExtensionInfo(BaseModel):
class UserExtension(BaseModel): class UserExtension(BaseModel):
user: str
extension: str extension: str
active: bool active: bool
extra: Optional[UserExtensionInfo] = None extra: Optional[UserExtensionInfo] = None
@@ -108,7 +132,7 @@ class UserExtension(BaseModel):
return self.extra.paid_to_enable is True return self.extra.paid_to_enable is True
@classmethod @classmethod
def from_row(cls, data: dict) -> "UserExtension": def from_row(cls, data: dict) -> UserExtension:
ext = UserExtension(**data) ext = UserExtension(**data)
ext.extra = ( ext.extra = (
UserExtensionInfo(**json.loads(data["_extra"] or "{}")) UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
@@ -118,106 +142,6 @@ class UserExtension(BaseModel):
return ext return ext
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
def file_hash(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, "rb", buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
async def fetch_github_repo_info(
org: str, repository: str
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
async def fetch_manifest(url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
async def fetch_github_release_config(
org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
async def fetch_release_payment_info(
url: str, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
if amount:
url = f"{url}?amount={amount}"
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
class Extension(NamedTuple): class Extension(NamedTuple):
code: str code: str
is_valid: bool is_valid: bool
@@ -225,7 +149,7 @@ class Extension(NamedTuple):
name: Optional[str] = None name: Optional[str] = None
short_description: Optional[str] = None short_description: Optional[str] = None
tile: Optional[str] = None tile: Optional[str] = None
contributors: Optional[List[str]] = None contributors: Optional[list[str]] = None
hidden: bool = False hidden: bool = False
migration_module: Optional[str] = None migration_module: Optional[str] = None
db_name: Optional[str] = None db_name: Optional[str] = None
@@ -247,7 +171,7 @@ class Extension(NamedTuple):
return self.upgrade_hash != "" return self.upgrade_hash != ""
@classmethod @classmethod
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension": def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
return Extension( return Extension(
code=ext_info.id, code=ext_info.id,
is_valid=True, is_valid=True,
@@ -256,22 +180,43 @@ class Extension(NamedTuple):
upgrade_hash=ext_info.hash if ext_info.module_installed else "", upgrade_hash=ext_info.hash if ext_info.module_installed else "",
) )
@classmethod
def get_valid_extensions(
cls, include_deactivated: Optional[bool] = True
) -> list[Extension]:
valid_extensions = [
extension for extension in cls._extensions() if extension.is_valid
]
# All subdirectories in the current directory, not recursive. if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
class ExtensionManager: return [
def __init__(self) -> None: e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
@classmethod
def get_valid_extension(
cls, ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
all_extensions = cls.get_valid_extensions(include_deactivated)
return next((e for e in all_extensions if e.code == ext_id), None)
@classmethod
def _extensions(cls) -> list[Extension]:
p = Path(settings.lnbits_extensions_path, "extensions") p = Path(settings.lnbits_extensions_path, "extensions")
Path(p).mkdir(parents=True, exist_ok=True) Path(p).mkdir(parents=True, exist_ok=True)
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()] extension_folders: list[Path] = [f for f in p.iterdir() if f.is_dir()]
@property
def extensions(self) -> List[Extension]:
# todo: remove this property somehow, it is too expensive # todo: remove this property somehow, it is too expensive
output: List[Extension] = [] output: list[Extension] = []
for extension_folder in self._extension_folders: for extension_folder in extension_folders:
extension_code = extension_folder.parts[-1] extension_code = extension_folder.parts[-1]
try: try:
with open(extension_folder / "config.json") as json_file: with open(extension_folder / "config.json") as json_file:
@@ -315,6 +260,7 @@ class ExtensionRelease(BaseModel):
warning: Optional[str] = None warning: Optional[str] = None
repo: Optional[str] = None repo: Optional[str] = None
icon: Optional[str] = None icon: Optional[str] = None
details_link: Optional[str] = None
pay_link: Optional[str] = None pay_link: Optional[str] = None
cost_sats: Optional[int] = None cost_sats: Optional[int] = None
@@ -333,13 +279,27 @@ class ExtensionRelease(BaseModel):
if not self.pay_link: if not self.pay_link:
return return
payment_info = await fetch_release_payment_info(self.pay_link) payment_info = await self.fetch_release_payment_info()
self.cost_sats = payment_info.amount if payment_info else None self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info(
self, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
assert url, "Missing URL for payment info."
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
@classmethod @classmethod
def from_github_release( def from_github_release(
cls, source_repo: str, r: "GitHubRepoRelease" cls, source_repo: str, r: GitHubRepoRelease
) -> "ExtensionRelease": ) -> ExtensionRelease:
return ExtensionRelease( return ExtensionRelease(
name=r.name, name=r.name,
description=r.name, description=r.name,
@@ -347,14 +307,15 @@ class ExtensionRelease(BaseModel):
archive=r.zipball_url, archive=r.zipball_url,
source_repo=source_repo, source_repo=source_repo,
is_github_release=True, is_github_release=True,
details_link=r.details_link(source_repo),
repo=f"https://github.com/{source_repo}", repo=f"https://github.com/{source_repo}",
html_url=r.html_url, html_url=r.html_url,
) )
@classmethod @classmethod
def from_explicit_release( def from_explicit_release(
cls, source_repo: str, e: "ExplicitRelease" cls, source_repo: str, e: ExplicitRelease
) -> "ExtensionRelease": ) -> ExtensionRelease:
return ExtensionRelease( return ExtensionRelease(
name=e.name, name=e.name,
version=e.version, version=e.version,
@@ -366,15 +327,16 @@ class ExtensionRelease(BaseModel):
is_version_compatible=e.is_version_compatible(), is_version_compatible=e.is_version_compatible(),
warning=e.warning, warning=e.warning,
html_url=e.html_url, html_url=e.html_url,
details_link=e.details_link,
pay_link=e.pay_link, pay_link=e.pay_link,
repo=e.repo, repo=e.repo,
icon=e.icon, icon=e.icon,
) )
@classmethod @classmethod
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]: async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try: try:
github_releases = await fetch_github_releases(org, repo) github_releases = await cls.fetch_github_releases(org, repo)
return [ return [
ExtensionRelease.from_github_release(f"{org}/{repo}", r) ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases for r in github_releases
@@ -383,30 +345,65 @@ class ExtensionRelease(BaseModel):
logger.warning(e) logger.warning(e)
return [] return []
@classmethod
async def fetch_github_releases(
cls, org: str, repo: str
) -> list[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
class ExtensionMeta(BaseModel):
installed_release: Optional[ExtensionRelease] = None
latest_release: Optional[ExtensionRelease] = None
pay_to_enable: Optional[PayToEnableInfo] = None
payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = []
archive: Optional[str] = None
featured: bool = False
class InstallableExtension(BaseModel): class InstallableExtension(BaseModel):
id: str id: str
name: str name: str
version: str
active: Optional[bool] = False active: Optional[bool] = False
short_description: Optional[str] = None short_description: Optional[str] = None
icon: Optional[str] = None icon: Optional[str] = None
dependencies: List[str] = []
is_admin_only: bool = False
stars: int = 0 stars: int = 0
featured = False meta: Optional[ExtensionMeta] = None
latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None @property
payments: List[ReleasePaymentInfo] = [] def is_admin_only(self) -> bool:
pay_to_enable: Optional[PayToEnableInfo] = None return self.id in settings.lnbits_admin_extensions
archive: Optional[str] = None
@property @property
def hash(self) -> str: def hash(self) -> str:
if self.installed_release: if self.meta and self.meta.installed_release:
if self.installed_release.hash: if self.meta.installed_release.hash:
return self.installed_release.hash return self.meta.installed_release.hash
m = hashlib.sha256() m = hashlib.sha256()
m.update(f"{self.installed_release.archive}".encode()) m.update(f"{self.meta.installed_release.archive}".encode())
return m.hexdigest() return m.hexdigest()
return "not-installed" return "not-installed"
@@ -444,15 +441,15 @@ class InstallableExtension(BaseModel):
@property @property
def installed_version(self) -> str: def installed_version(self) -> str:
if self.installed_release: if self.meta and self.meta.installed_release:
return self.installed_release.version return self.meta.installed_release.version
return "" return ""
@property @property
def requires_payment(self) -> bool: def requires_payment(self) -> bool:
if not self.pay_to_enable: if not self.meta or not self.meta.pay_to_enable:
return False return False
return self.pay_to_enable.required is True return self.meta.pay_to_enable.required is True
async def download_archive(self): async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).") logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
@@ -460,12 +457,14 @@ class InstallableExtension(BaseModel):
if ext_zip_file.is_file(): if ext_zip_file.is_file():
os.remove(ext_zip_file) os.remove(ext_zip_file)
try: try:
assert self.installed_release, "installed_release is none." assert (
self.meta and self.meta.installed_release
), "installed_release is none."
self._restore_payment_info() self._restore_payment_info()
await asyncio.to_thread( await asyncio.to_thread(
download_url, self.installed_release.archive_url, ext_zip_file download_url, self.meta.installed_release.archive_url, ext_zip_file
) )
self._remember_payment_info() self._remember_payment_info()
@@ -475,7 +474,11 @@ class InstallableExtension(BaseModel):
raise AssertionError("Cannot fetch extension archive file") from exc raise AssertionError("Cannot fetch extension archive file") from exc
archive_hash = file_hash(ext_zip_file) archive_hash = file_hash(ext_zip_file)
if self.installed_release.hash and self.installed_release.hash != archive_hash: if (
self.meta
and self.meta.installed_release.hash
and self.meta.installed_release.hash != archive_hash
):
# remove downloaded archive # remove downloaded archive
if ext_zip_file.is_file(): if ext_zip_file.is_file():
os.remove(ext_zip_file) os.remove(ext_zip_file)
@@ -509,27 +512,18 @@ class InstallableExtension(BaseModel):
self.short_description = config_json.get("short_description") self.short_description = config_json.get("short_description")
if ( if (
self.installed_release self.meta
and self.installed_release.is_github_release and self.meta.installed_release
and self.meta.installed_release.is_github_release
and config_json.get("tile") and config_json.get("tile")
): ):
self.icon = icon_to_github_url( self.icon = icon_to_github_url(
self.installed_release.source_repo, config_json.get("tile") self.meta.installed_release.source_repo, config_json.get("tile")
) )
shutil.rmtree(self.ext_dir, True) shutil.rmtree(self.ext_dir, True)
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir)) shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.success(f"Extension {self.name} ({self.installed_version}) installed.") logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
def notify_upgrade(self, upgrade_hash: Optional[str]) -> None:
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
settings.lnbits_upgraded_extensions.add(f"{self.hash}/{self.id}")
settings.lnbits_all_extensions_ids.add(self.id)
def clean_extension_files(self): def clean_extension_files(self):
# remove downloaded archive # remove downloaded archive
@@ -544,87 +538,78 @@ class InstallableExtension(BaseModel):
def check_latest_version(self, release: Optional[ExtensionRelease]): def check_latest_version(self, release: Optional[ExtensionRelease]):
if not release: if not release:
return return
if not self.latest_release: if not self.meta or not self.meta.latest_release:
self.latest_release = release meta = self.meta or ExtensionMeta()
meta.latest_release = release
self.meta = meta
return return
if version_parse(self.latest_release.version) < version_parse(release.version): if version_parse(self.meta.latest_release.version) < version_parse(
self.latest_release = release release.version
):
self.meta.latest_release = release
def find_existing_payment( def find_existing_payment(
self, pay_link: Optional[str] self, pay_link: Optional[str]
) -> Optional[ReleasePaymentInfo]: ) -> Optional[ReleasePaymentInfo]:
if not pay_link: if not pay_link or not self.meta or not self.meta.payments:
return None return None
return next( return next(
(p for p in self.payments if p.pay_link == pay_link), (p for p in self.meta.payments if p.pay_link == pay_link),
None, None,
) )
def _restore_payment_info(self): def _restore_payment_info(self):
if not self.installed_release: if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
or not self.meta.installed_release.payment_hash
):
return return
if not self.installed_release.pay_link: payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
return
if self.installed_release.payment_hash:
return
payment_info = self.find_existing_payment(self.installed_release.pay_link)
if payment_info: if payment_info:
self.installed_release.payment_hash = payment_info.payment_hash self.meta.installed_release.payment_hash = payment_info.payment_hash
def _remember_payment_info(self): def _remember_payment_info(self):
if not self.installed_release or not self.installed_release.pay_link: if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
):
return return
payment_info = ReleasePaymentInfo( payment_info = ReleasePaymentInfo(
amount=self.installed_release.cost_sats, amount=self.meta.installed_release.cost_sats,
pay_link=self.installed_release.pay_link, pay_link=self.meta.installed_release.pay_link,
payment_hash=self.installed_release.payment_hash, payment_hash=self.meta.installed_release.payment_hash,
) )
self.payments = [ self.meta.payments = [
p for p in self.payments if p.pay_link != payment_info.pay_link p for p in self.meta.payments if p.pay_link != payment_info.pay_link
] ]
self.payments.append(payment_info) self.meta.payments.append(payment_info)
@classmethod
def from_row(cls, data: dict) -> "InstallableExtension":
meta = json.loads(data["meta"])
ext = InstallableExtension(**data)
if "installed_release" in meta:
ext.installed_release = ExtensionRelease(**meta["installed_release"])
if meta.get("pay_to_enable"):
ext.pay_to_enable = PayToEnableInfo(**meta["pay_to_enable"])
if meta.get("payments"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext
@classmethod
def from_rows(
cls, rows: Optional[List[Any]] = None
) -> List["InstallableExtension"]:
if rows is None:
rows = []
return [InstallableExtension.from_row(row) for row in rows]
@classmethod @classmethod
async def from_github_release( async def from_github_release(
cls, github_release: GitHubRelease cls, github_release: GitHubRelease
) -> Optional["InstallableExtension"]: ) -> Optional[InstallableExtension]:
try: try:
repo, latest_release, config = await fetch_github_repo_info( repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository github_release.organisation, github_release.repository
) )
source_repo = f"{github_release.organisation}/{github_release.repository}"
return InstallableExtension( return InstallableExtension(
id=github_release.id, id=github_release.id,
name=config.name, name=config.name,
version=latest_release.tag_name,
short_description=config.short_description, short_description=config.short_description,
stars=int(repo.stargazers_count), stars=int(repo.stargazers_count),
icon=icon_to_github_url( icon=icon_to_github_url(
f"{github_release.organisation}/{github_release.repository}", source_repo,
config.tile, config.tile,
), ),
latest_release=ExtensionRelease.from_github_release( meta=ExtensionMeta(
repo.html_url, latest_release latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
), ),
) )
except Exception as e: except Exception as e:
@@ -632,26 +617,27 @@ class InstallableExtension(BaseModel):
return None return None
@classmethod @classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> "InstallableExtension": def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
return InstallableExtension( return InstallableExtension(
id=e.id, id=e.id,
name=e.name, name=e.name,
archive=e.archive, version=e.version,
short_description=e.short_description, short_description=e.short_description,
icon=e.icon, icon=e.icon,
dependencies=e.dependencies, meta=meta,
) )
@classmethod @classmethod
async def get_installable_extensions( async def get_installable_extensions(
cls, cls,
) -> List["InstallableExtension"]: ) -> list[InstallableExtension]:
extension_list: List[InstallableExtension] = [] extension_list: list[InstallableExtension] = []
extension_id_list: List[str] = [] extension_id_list: list[str] = []
for url in settings.lnbits_extensions_manifests: for url in settings.lnbits_extensions_manifests:
try: try:
manifest = await fetch_manifest(url) manifest = await cls.fetch_manifest(url)
for r in manifest.repos: for r in manifest.repos:
ext = await InstallableExtension.from_github_release(r) ext = await InstallableExtension.from_github_release(r)
@@ -660,11 +646,13 @@ class InstallableExtension(BaseModel):
existing_ext = next( existing_ext = next(
(ee for ee in extension_list if ee.id == r.id), None (ee for ee in extension_list if ee.id == r.id), None
) )
if existing_ext: if existing_ext and ext.meta:
existing_ext.check_latest_version(ext.latest_release) existing_ext.check_latest_version(ext.meta.latest_release)
continue continue
ext.featured = ext.id in manifest.featured meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext] extension_list += [ext]
extension_id_list += [ext.id] extension_id_list += [ext.id]
@@ -678,7 +666,9 @@ class InstallableExtension(BaseModel):
continue continue
ext = InstallableExtension.from_explicit_release(e) ext = InstallableExtension.from_explicit_release(e)
ext.check_latest_version(release) ext.check_latest_version(release)
ext.featured = ext.id in manifest.featured meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext] extension_list += [ext]
extension_id_list += [e.id] extension_id_list += [e.id]
except Exception as e: except Exception as e:
@@ -687,12 +677,12 @@ class InstallableExtension(BaseModel):
return extension_list return extension_list
@classmethod @classmethod
async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]: async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: List[ExtensionRelease] = [] extension_releases: list[ExtensionRelease] = []
for url in settings.lnbits_extensions_manifests: for url in settings.lnbits_extensions_manifests:
try: try:
manifest = await fetch_manifest(url) manifest = await cls.fetch_manifest(url)
for r in manifest.repos: for r in manifest.repos:
if r.id != ext_id: if r.id != ext_id:
continue continue
@@ -716,8 +706,8 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_extension_release( async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str cls, ext_id: str, source_repo: str, archive: str, version: str
) -> Optional["ExtensionRelease"]: ) -> Optional[ExtensionRelease]:
all_releases: List[ExtensionRelease] = ( all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id) await InstallableExtension.get_extension_releases(ext_id)
) )
selected_release = [ selected_release = [
@@ -730,6 +720,37 @@ class InstallableExtension(BaseModel):
return selected_release[0] if len(selected_release) != 0 else None return selected_release[0] if len(selected_release) != 0 else None
@classmethod
async def fetch_github_repo_info(
cls, org: str, repository: str
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
class CreateExtension(BaseModel): class CreateExtension(BaseModel):
ext_id: str ext_id: str
@@ -740,30 +761,7 @@ class CreateExtension(BaseModel):
payment_hash: Optional[str] = None payment_hash: Optional[str] = None
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]: class ExtensionDetailsRequest(BaseModel):
valid_extensions = [ ext_id: str
extension for extension in ExtensionManager().extensions if extension.is_valid source_repo: str
] version: str
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
return version.parse(v)
except Exception:
return version.parse("0.0.0")
+81 -73
View File
@@ -1,42 +1,51 @@
import importlib import importlib
import re import re
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
import httpx
from loguru import logger from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_db_versions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db from lnbits.core.db import db as core_db
from lnbits.db import Connection from lnbits.core.extensions.models import InstallableExtension
from lnbits.extension_manager import Extension from lnbits.core.models import DbVersion
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings from lnbits.settings import settings
from .crud import update_migration_version
async def migrate_extension_database(
ext: InstallableExtension, current_version: Optional[DbVersion] = None
):
async def migrate_extension_database(ext: Extension, current_version):
try: try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db ext_db = importlib.import_module(ext.module_name).db
except ImportError as exc: except ImportError as exc:
logger.error(exc) logger.error(exc)
raise ImportError( raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
f"Please make sure that the extension `{ext.code}` has a migrations file."
) from exc
async with ext_db.connect() as ext_conn: async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations, ext.code, current_version) await run_migration(ext_conn, ext_migrations, ext.id, current_version)
async def run_migration( async def run_migration(
db: Connection, migrations_module: Any, db_name: str, current_version: int db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
): ):
matcher = re.compile(r"^m(\d\d\d)_") matcher = re.compile(r"^m(\d\d\d)_")
for key, migrate in migrations_module.__dict__.items(): for key, migrate in migrations_module.__dict__.items():
match = matcher.match(key) match = matcher.match(key)
if match: if match:
version = int(match.group(1)) version = int(match.group(1))
if version > current_version: if not current_version or version > current_version.version:
logger.debug(f"running migration {db_name}.{version}") logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}") print(f"running migration {db_name}.{version}")
await migrate(db) await migrate(db)
@@ -48,68 +57,6 @@ async def run_migration(
await update_migration_version(conn, db_name, version) await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(
ext_id: str, user: str, access_token: Optional[str] = None
):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
at the root level of their API.
"""
stopped = await _stop_extension_background_work(ext_id)
if not stopped:
# fallback to REST API call
await _stop_extension_background_work_via_api(ext_id, user, access_token)
async def _stop_extension_background_work(ext_id) -> bool:
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
await stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
logger.info(
f"Stopping background work for extension '{ext_id}' using the REST API."
)
async with httpx.AsyncClient() as client:
try:
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
headers = (
{"Authorization": "Bearer " + access_token} if access_token else None
)
resp = await client.delete(url=url, headers=headers)
resp.raise_for_status()
logger.info(f"Stopped background work for extension '{ext_id}'.")
except Exception as ex:
logger.warning(
f"Failed to stop background work for '{ext_id}' using the REST API."
)
logger.warning(ex)
def to_valid_user_id(user_id: str) -> UUID: def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32: if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits") raise ValueError("User ID must have at least 128 bits")
@@ -119,3 +66,64 @@ def to_valid_user_id(user_id: str) -> UUID:
raise ValueError("Invalid hex string for User ID.") from exc raise ValueError("Invalid hex string for User ID.") from exc
return UUID(hex=user_id[:32], version=4) return UUID(hex=user_id[:32], version=4)
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_db_versions(conn)
core_version = next(
(v for v in current_versions if v.db == "core"),
DbVersion(db="core", version=0),
)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in await get_installed_extensions():
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
)
continue
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.id}: {e}")
logger.info("✔️ All migrations done.")
def is_valid_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+117 -51
View File
@@ -1,4 +1,3 @@
import datetime
from time import time from time import time
from loguru import logger from loguru import logger
@@ -102,7 +101,7 @@ async def m002_add_fields_to_apipayments(db):
import json import json
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall() rows = await db.fetchall("SELECT * FROM apipayments")
for row in rows: for row in rows:
if not row["memo"] or not row["memo"].startswith("#"): if not row["memo"] or not row["memo"].startswith("#"):
continue continue
@@ -113,15 +112,15 @@ async def m002_add_fields_to_apipayments(db):
new = row["memo"][len(prefix) :] new = row["memo"][len(prefix) :]
await db.execute( await db.execute(
""" """
UPDATE apipayments SET extra = ?, memo = ? UPDATE apipayments SET extra = :extra, memo = :memo1
WHERE checking_id = ? AND memo = ? WHERE checking_id = :checking_id AND memo = :memo2
""", """,
( {
json.dumps({"tag": ext}), "extra": json.dumps({"tag": ext}),
new, "memo1": new,
row["checking_id"], "checking_id": row["checking_id"],
row["memo"], "memo2": row["memo"],
), },
) )
break break
except OperationalError: except OperationalError:
@@ -212,19 +211,17 @@ async def m007_set_invoice_expiries(db):
Precomputes invoice expiry for existing pending incoming payments. Precomputes invoice expiry for existing pending incoming payments.
""" """
try: try:
rows = await ( rows = await db.fetchall(
await db.execute( f"""
f""" SELECT bolt11, checking_id
SELECT bolt11, checking_id FROM apipayments
FROM apipayments WHERE pending = true
WHERE pending = true AND amount > 0
AND amount > 0 AND bolt11 IS NOT NULL
AND bolt11 IS NOT NULL AND expiry IS NULL
AND expiry IS NULL AND time < {db.timestamp_now}
AND time < {db.timestamp_now} """
""" )
)
).fetchall()
if len(rows): if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices") logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, ( for i, (
@@ -236,22 +233,17 @@ async def m007_set_invoice_expiries(db):
if invoice.expiry is None: if invoice.expiry is None:
continue continue
expiration_date = datetime.datetime.fromtimestamp( expiration_date = invoice.date + invoice.expiry
invoice.date + invoice.expiry
)
logger.info( logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice" f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}" f" {invoice.payment_hash} to {expiration_date}"
) )
await db.execute( await db.execute(
""" f"""
UPDATE apipayments SET expiry = ? UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = ? AND amount > 0 WHERE checking_id = :checking_id AND amount > 0
""", """,
( {"expiry": expiration_date, "checking_id": checking_id},
db.datetime_to_timestamp(expiration_date),
checking_id,
),
) )
except Exception: except Exception:
continue continue
@@ -347,17 +339,15 @@ async def m014_set_deleted_wallets(db):
Sets deleted column to wallets. Sets deleted column to wallets.
""" """
try: try:
rows = await ( rows = await db.fetchall(
await db.execute( """
""" SELECT *
SELECT * FROM wallets
FROM wallets WHERE user LIKE 'del:%'
WHERE user LIKE 'del:%' AND adminkey LIKE 'del:%'
AND adminkey LIKE 'del:%' AND inkey LIKE 'del:%'
AND inkey LIKE 'del:%' """
""" )
)
).fetchall()
for row in rows: for row in rows:
try: try:
@@ -367,10 +357,15 @@ async def m014_set_deleted_wallets(db):
await db.execute( await db.execute(
""" """
UPDATE wallets SET UPDATE wallets SET
"user" = ?, adminkey = ?, inkey = ?, deleted = true "user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
WHERE id = ? WHERE id = :wallet
""", """,
(user, adminkey, inkey, row[0]), {
"user": user,
"adminkey": adminkey,
"inkey": inkey,
"wallet": row.get("id"),
},
) )
except Exception: except Exception:
continue continue
@@ -456,17 +451,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
now = int(time()) now = int(time())
await db.execute( await db.execute(
f""" f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder} UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", """,
(now,), {"now": now},
) )
await db.execute( await db.execute(
f""" f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder} UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", """,
(now,), {"now": now},
) )
except OperationalError as exc: except OperationalError as exc:
@@ -520,3 +515,74 @@ async def m020_add_column_column_to_user_extensions(db):
Adds extra column to user extensions. Adds extra column to user extensions.
""" """
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT") await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
async def m021_add_success_failed_to_apipayments(db):
"""
Adds success and failed columns to apipayments.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN status TEXT DEFAULT 'pending'")
# set all not pending to success true, failed payments were deleted until now
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet
"""
)
async def m022_add_pubkey_to_accounts(db):
"""
Adds pubkey column to accounts.
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
except OperationalError:
pass
async def m023_add_column_column_to_apipayments(db):
"""
renames hash to payment_hash and drops unused index
"""
await db.execute("DROP INDEX by_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN hash TO payment_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN wallet TO wallet_id")
await db.execute("ALTER TABLE accounts RENAME COLUMN pass TO password_hash")
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
async def m024_drop_pending(db):
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m025_refresh_view(db):
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet_id,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet_id
"""
)
+157 -141
View File
@@ -1,24 +1,29 @@
import datetime from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
import json
import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum from enum import Enum
from sqlite3 import Row from typing import Callable, Optional
from typing import Callable, Dict, List, Optional
from ecdsa import SECP256k1, SigningKey from ecdsa import SECP256k1, SigningKey
from fastapi import Query from fastapi import Query
from loguru import logger from passlib.context import CryptContext
from pydantic import BaseModel from pydantic import BaseModel, Field, validator
from lnbits.db import Connection, FilterModel, FromRowModel from lnbits.db import FilterModel
from lnbits.helpers import url_for from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
class BaseWallet(BaseModel): class BaseWallet(BaseModel):
@@ -29,16 +34,21 @@ class BaseWallet(BaseModel):
balance_msat: int balance_msat: int
class Wallet(BaseWallet): class Wallet(BaseModel):
id: str
user: str user: str
currency: Optional[str] name: str
deleted: bool adminkey: str
created_at: Optional[int] = None inkey: str
updated_at: Optional[int] = None deleted: bool = False
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
currency: Optional[str] = None
balance_msat: int = Field(default=0, no_database=True)
@property @property
def balance(self) -> int: def balance(self) -> int:
return self.balance_msat // 1000 return int(self.balance_msat // 1000)
@property @property
def withdrawable_balance(self) -> int: def withdrawable_balance(self) -> int:
@@ -62,11 +72,6 @@ class Wallet(BaseWallet):
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256 linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
) )
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash)
class KeyType(Enum): class KeyType(Enum):
admin = 0 admin = 0
@@ -84,7 +89,7 @@ class WalletTypeInfo:
wallet: Wallet wallet: Wallet
class UserConfig(BaseModel): class UserExtra(BaseModel):
email_verified: Optional[bool] = False email_verified: Optional[bool] = False
first_name: Optional[str] = None first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
@@ -97,16 +102,43 @@ class UserConfig(BaseModel):
provider: Optional[str] = "lnbits" # auth provider provider: Optional[str] = "lnbits" # auth provider
class Account(FromRowModel): class Account(BaseModel):
id: str id: str
is_super_user: Optional[bool] = False
is_admin: Optional[bool] = False
username: Optional[str] = None username: Optional[str] = None
password_hash: Optional[str] = None
pubkey: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
balance_msat: Optional[int] = 0 extra: UserExtra = UserExtra()
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
@property
def is_super_user(self) -> bool:
return self.id == settings.super_user
@property
def is_admin(self) -> bool:
return self.id in settings.lnbits_admin_users or self.is_super_user
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.password_hash = pwd_context.hash(password)
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
class AccountOverview(Account):
transaction_count: Optional[int] = 0 transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0 wallet_count: Optional[int] = 0
last_payment: Optional[datetime.datetime] = None balance_msat: Optional[int] = 0
last_payment: Optional[datetime] = None
class AccountFilters(FilterModel): class AccountFilters(FilterModel):
@@ -121,7 +153,7 @@ class AccountFilters(FilterModel):
] ]
id: str id: str
last_payment: Optional[datetime.datetime] = None last_payment: Optional[datetime] = None
transaction_count: Optional[int] = None transaction_count: Optional[int] = None
wallet_count: Optional[int] = None wallet_count: Optional[int] = None
username: Optional[str] = None username: Optional[str] = None
@@ -130,22 +162,23 @@ class AccountFilters(FilterModel):
class User(BaseModel): class User(BaseModel):
id: str id: str
created_at: datetime
updated_at: datetime
email: Optional[str] = None email: Optional[str] = None
username: Optional[str] = None username: Optional[str] = None
extensions: List[str] = [] pubkey: Optional[str] = None
wallets: List[Wallet] = [] extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False admin: bool = False
super_user: bool = False super_user: bool = False
has_password: bool = False has_password: bool = False
config: Optional[UserConfig] = None extra: UserExtra = UserExtra()
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property @property
def wallet_ids(self) -> List[str]: def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets] return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]: def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id] w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None return w[0] if w else None
@@ -171,15 +204,26 @@ class UpdateUser(BaseModel):
user_id: str user_id: str
email: Optional[str] = Query(default=None) email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20) username: Optional[str] = Query(default=..., min_length=2, max_length=20)
config: Optional[UserConfig] = None extra: Optional[UserExtra] = None
class UpdateUserPassword(BaseModel): class UpdateUserPassword(BaseModel):
user_id: str user_id: str
password_old: Optional[str] = None
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
username: str = Query(default=..., min_length=2, max_length=20)
class UpdateUserPubkey(BaseModel):
user_id: str
pubkey: str = Query(default=..., max_length=64)
class ResetUserPassword(BaseModel):
reset_key: str
password: str = Query(default=..., min_length=8, max_length=50) password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50) password_repeat: str = Query(default=..., min_length=8, max_length=50)
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
class UpdateSuperuserPassword(BaseModel): class UpdateSuperuserPassword(BaseModel):
@@ -197,40 +241,62 @@ class LoginUsernamePassword(BaseModel):
password: str password: str
class Payment(FromRowModel): class AccessTokenPayload(BaseModel):
sub: str
usr: Optional[str] = None
email: Optional[str] = None
auth_time: Optional[int] = 0
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class CreatePayment(BaseModel):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
preimage: Optional[str] = None
expiry: Optional[datetime] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(BaseModel):
status: str
checking_id: str checking_id: str
pending: bool payment_hash: str
wallet_id: str
amount: int amount: int
fee: int fee: int
memo: Optional[str] memo: Optional[str]
time: int time: datetime
bolt11: str bolt11: str
preimage: str expiry: Optional[datetime]
payment_hash: str extra: Optional[dict]
expiry: Optional[float]
extra: Dict = {}
wallet_id: str
webhook: Optional[str] webhook: Optional[str]
webhook_status: Optional[int] webhook_status: Optional[int] = None
preimage: Optional[str] = "0" * 64
@classmethod @property
def from_row(cls, row: Row): def pending(self) -> bool:
return cls( return self.status == PaymentState.PENDING.value
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64, @property
bolt11=row["bolt11"] or "", def success(self) -> bool:
preimage=row["preimage"] or "0" * 64, return self.status == PaymentState.SUCCESS.value
extra=json.loads(row["extra"] or "{}"),
pending=row["pending"], @property
amount=row["amount"], def failed(self) -> bool:
fee=row["fee"], return self.status == PaymentState.FAILED.value
memo=row["memo"],
time=row["time"],
expiry=row["expiry"],
wallet_id=row["wallet"],
webhook=row["webhook"],
webhook_status=row["webhook_status"],
)
@property @property
def tag(self) -> Optional[str]: def tag(self) -> Optional[str]:
@@ -256,86 +322,26 @@ class Payment(FromRowModel):
@property @property
def is_expired(self) -> bool: def is_expired(self) -> bool:
return self.expiry < time.time() if self.expiry else False return self.expiry < datetime.now(timezone.utc) if self.expiry else False
@property @property
def is_uncheckable(self) -> bool: def is_internal(self) -> bool:
return self.checking_id.startswith("internal_") return self.checking_id.startswith("internal_")
async def update_status( async def check_status(self) -> PaymentStatus:
self, if self.is_internal:
status: PaymentStatus, if self.success:
conn: Optional[Connection] = None, return PaymentSuccessStatus()
) -> None: if self.failed:
from .crud import update_payment_details return PaymentFailedStatus()
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
conn=conn,
)
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
self.pending = pending
await update_payment_status(self.checking_id, pending)
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable:
return PaymentPendingStatus() return PaymentPendingStatus()
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
funding_source = get_funding_source() funding_source = get_funding_source()
if self.is_out: if self.is_out:
status = await funding_source.get_payment_status(self.checking_id) status = await funding_source.get_payment_status(self.checking_id)
else: else:
status = await funding_source.get_invoice_status(self.checking_id) status = await funding_source.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: "
f"expired {expiration_date}"
)
await self.delete(conn)
# wait at least 15 minutes before deleting failed outgoing payments
elif self.is_out and status.failed:
if self.time + 900 < int(time.time()):
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete(conn)
else:
logger.warning(
f"Tried to delete outgoing payment {self.checking_id}: "
"skipping because it's not old enough"
)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' "
f"{self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_wallet_payment
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"] __search_fields__ = ["memo", "amount"]
@@ -344,19 +350,19 @@ class PaymentFilters(FilterModel):
amount: int amount: int
fee: int fee: int
memo: Optional[str] memo: Optional[str]
time: datetime.datetime time: datetime
bolt11: str bolt11: str
preimage: str preimage: str
payment_hash: str payment_hash: str
expiry: Optional[datetime.datetime] expiry: Optional[datetime]
extra: Dict = {} extra: dict = {}
wallet_id: str wallet_id: str
webhook: Optional[str] webhook: Optional[str]
webhook_status: Optional[int] webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel): class PaymentHistoryPoint(BaseModel):
date: datetime.datetime date: datetime
income: int income: int
spending: int spending: int
balance: int balance: int
@@ -378,10 +384,6 @@ class TinyURL(BaseModel):
wallet: str wallet: str
time: float time: float
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class ConversionData(BaseModel): class ConversionData(BaseModel):
from_: str = "sat" from_: str = "sat"
@@ -395,6 +397,7 @@ class Callback(BaseModel):
class DecodePayment(BaseModel): class DecodePayment(BaseModel):
data: str data: str
filter_fields: Optional[list[str]] = []
class CreateLnurl(BaseModel): class CreateLnurl(BaseModel):
@@ -420,6 +423,14 @@ class CreateInvoice(BaseModel):
bolt11: Optional[str] = None bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None lnurl_callback: Optional[str] = None
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
class CreateTopup(BaseModel): class CreateTopup(BaseModel):
id: str id: str
@@ -443,7 +454,7 @@ class WebPushSubscription(BaseModel):
user: str user: str
data: str data: str
host: str host: str
timestamp: str timestamp: datetime
class BalanceDelta(BaseModel): class BalanceDelta(BaseModel):
@@ -458,3 +469,8 @@ class BalanceDelta(BaseModel):
class SimpleStatus(BaseModel): class SimpleStatus(BaseModel):
success: bool success: bool
message: str message: str
class DbVersion(BaseModel):
db: str
version: int
+228 -132
View File
@@ -1,13 +1,14 @@
import asyncio import asyncio
import datetime
import json import json
import time import time
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict from typing import Optional
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx import httpx
from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket from fastapi import Depends, WebSocket
@@ -16,12 +17,14 @@ from py_vapid import Vapid
from py_vapid.utils import b64urlencode from py_vapid.utils import b64urlencode
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.extensions.models import UserExtension
from lnbits.db import Connection from lnbits.db import Connection
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
check_user_extension_access, check_user_extension_access,
require_admin_key, require_admin_key,
) )
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.helpers import url_for from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl from lnbits.lnurl import decode as decode_lnurl
@@ -43,47 +46,48 @@ from lnbits.wallets.base import (
from .crud import ( from .crud import (
check_internal, check_internal,
check_internal_pending,
create_account, create_account,
create_admin_settings, create_admin_settings,
create_payment, create_payment,
create_wallet, create_wallet,
delete_wallet_payment,
get_account, get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_payment,
get_payments, get_payments,
get_standalone_payment, get_standalone_payment,
get_super_settings, get_super_settings,
get_total_balance, get_total_balance,
get_user,
get_wallet, get_wallet,
get_wallet_payment, get_wallet_payment,
is_internal_status_success,
update_admin_settings, update_admin_settings,
update_payment_details, update_payment,
update_payment_status,
update_super_user, update_super_user,
update_user_extension,
) )
from .helpers import to_valid_user_id from .helpers import to_valid_user_id
from .models import BalanceDelta, Payment, UserConfig, Wallet from .models import (
Account,
BalanceDelta,
class PaymentError(Exception): CreatePayment,
def __init__(self, message: str, status: str = "pending"): Payment,
self.message = message PaymentState,
self.status = status User,
UserExtra,
Wallet,
class InvoiceError(Exception): )
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
async def calculate_fiat_amounts( async def calculate_fiat_amounts(
amount: float, amount: float,
wallet_id: str, wallet_id: str,
currency: Optional[str] = None, currency: Optional[str] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Tuple[int, Optional[Dict]]: ) -> tuple[int, Optional[dict]]:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id" assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
@@ -124,11 +128,11 @@ async def create_invoice(
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None, unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None, expiry: Optional[int] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
webhook: Optional[str] = None, webhook: Optional[str] = None,
internal: Optional[bool] = False, internal: Optional[bool] = False,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Tuple[str, str]: ) -> tuple[str, str]:
if not amount > 0: if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed") raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -173,17 +177,20 @@ async def create_invoice(
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
amount_msat = 1000 * amount_sat create_payment_model = CreatePayment(
await create_payment(
wallet_id=wallet_id, wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount=amount_msat, amount=amount_sat * 1000,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=memo, memo=memo,
extra=extra, extra=extra,
webhook=webhook, webhook=webhook,
)
await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn, conn=conn,
) )
@@ -195,7 +202,7 @@ async def pay_invoice(
wallet_id: str, wallet_id: str,
payment_request: str, payment_request: str,
max_sat: Optional[int] = None, max_sat: Optional[int] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
description: str = "", description: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> str: ) -> str:
@@ -229,17 +236,7 @@ async def pay_invoice(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
) )
# put all parameters that don't change here create_payment_model = CreatePayment(
class PaymentKwargs(TypedDict):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
expiry: Optional[datetime.datetime]
extra: Optional[Dict]
payment_kwargs: PaymentKwargs = PaymentKwargs(
wallet_id=wallet_id, wallet_id=wallet_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
@@ -249,22 +246,17 @@ async def pay_invoice(
extra=extra, extra=extra,
) )
# we check if an internal invoice exists that has already been paid if await is_internal_status_success(invoice.payment_hash, conn=conn):
# (not pending anymore)
if not await check_internal_pending(invoice.payment_hash, conn=conn):
raise PaymentError("Internal invoice already paid.", status="failed") raise PaymentError("Internal invoice already paid.", status="failed")
# check_internal() returns the checking_id of the invoice we're waiting for # check_internal() returns the checking_id of the invoice we're waiting for
# (pending only) # (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn) internal_payment = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id: if internal_payment:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
# perform additional checks on the internal payment # perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice # the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment( internal_invoice = await get_standalone_payment(
internal_checking_id, incoming=True, conn=conn internal_payment.checking_id, incoming=True, conn=conn
) )
assert internal_invoice is not None assert internal_invoice is not None
if ( if (
@@ -275,36 +267,30 @@ async def pay_invoice(
logger.debug(f"creating temporary internal payment with id {internal_id}") logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet # create a new payment from this wallet
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
create_payment_model.fee = abs(fee_reserve_total_msat)
new_payment = await create_payment( new_payment = await create_payment(
checking_id=internal_id, checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat), data=create_payment_model,
pending=False, status=PaymentState.SUCCESS,
conn=conn, conn=conn,
**payment_kwargs,
) )
else: else:
fee_reserve_total_msat = fee_reserve_total( new_payment = await _create_external_payment(
invoice.amount_msat, internal=False temp_id=temp_id,
amount_msat=invoice.amount_msat,
data=create_payment_model,
conn=conn,
) )
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment.", status="failed") from exc
# do the balance check # do the balance check
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched" assert wallet, "Wallet for balancecheck could not be fetched"
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id) fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_payment)
if extra and "tag" in extra: if extra and "tag" in extra:
# check if the payment is made for an extension that the user disabled # check if the payment is made for an extension that the user disabled
@@ -312,71 +298,71 @@ async def pay_invoice(
if not status.success: if not status.success:
raise PaymentError(status.message) raise PaymentError(status.message)
if internal_checking_id: if internal_payment:
service_fee_msat = service_fee(invoice.amount_msat, internal=True) service_fee_msat = service_fee(invoice.amount_msat, internal=True)
logger.debug(f"marking temporary payment as not pending {internal_checking_id}") logger.debug(
f"marking temporary payment as not pending {internal_payment.checking_id}"
)
# mark the invoice from the other side as not pending anymore # mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure # so the other side only has access to his new money when we are sure
# the payer has enough to deduct from # the payer has enough to deduct from
async with db.connect() as conn: async with db.connect() as conn:
await update_payment_status( internal_payment.status = PaymentState.SUCCESS
checking_id=internal_checking_id, pending=False, conn=conn await update_payment(internal_payment, conn=conn)
)
await send_payment_notification(wallet, new_payment) await send_payment_notification(wallet, new_payment)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_checking_id}") logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_checking_id) await internal_invoice_queue.put(internal_payment.checking_id)
else: else:
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False) fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
service_fee_msat = service_fee(invoice.amount_msat, internal=False) service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}") logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice # actually pay the external invoice
funding_source = get_funding_source() funding_source = get_funding_source()
payment: PaymentResponse = await funding_source.pay_invoice( payment_response: PaymentResponse = await funding_source.pay_invoice(
payment_request, fee_reserve_msat payment_request, fee_reserve_msat
) )
if payment.checking_id and payment.checking_id != temp_id: if payment_response.checking_id and payment_response.checking_id != temp_id:
logger.warning( logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got:" f"backend sent unexpected checking_id (expected: {temp_id} got:"
f" {payment.checking_id})" f" {payment_response.checking_id})"
) )
logger.debug(f"backend: pay_invoice finished {temp_id}") logger.debug(f"backend: pay_invoice finished {temp_id}, {payment_response}")
logger.debug(f"backend: pay_invoice response {payment}") if payment_response.checking_id and payment_response.ok is not False:
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)! # payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}") logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn: async with db.connect() as conn:
await update_payment_details( payment = await get_payment(temp_id, conn=conn)
checking_id=temp_id, # new checking id
pending=payment.ok is not True, payment.checking_id = payment_response.checking_id
fee=-( payment.status = (
abs(payment.fee_msat if payment.fee_msat else 0) PaymentState.SUCCESS
+ abs(service_fee_msat) if payment_response.ok is True
), else PaymentState.PENDING
preimage=payment.preimage,
new_checking_id=payment.checking_id,
conn=conn,
) )
payment.fee = -(
abs(payment_response.fee_msat or 0) + abs(service_fee_msat)
)
payment.preimage = payment_response.preimage
await update_payment(payment, conn=conn)
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id, conn=conn)
updated = await get_wallet_payment( if wallet:
wallet_id, payment.checking_id, conn=conn await send_payment_notification(wallet, payment)
) logger.success(f"payment successful {payment_response.checking_id}")
if wallet and updated: elif payment_response.checking_id is None and payment_response.ok is False:
await send_payment_notification(wallet, updated)
logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed # payment failed
logger.warning("backend sent payment failure") logger.debug(f"payment failed {temp_id}, {payment_response.error_message}")
async with db.connect() as conn: async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}") payment = await get_payment(temp_id, conn=conn)
await delete_wallet_payment(temp_id, wallet_id, conn=conn) payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
raise PaymentError( raise PaymentError(
f"Payment failed: {payment.error_message}" f"Payment failed: {payment_response.error_message}"
or "Payment failed, but backend didn't give us an error message.", or "Payment failed, but backend didn't give us an error message.",
status="failed", status="failed",
) )
@@ -388,27 +374,79 @@ async def pay_invoice(
# credit service fee wallet # credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat: if settings.lnbits_service_fee_wallet and service_fee_msat:
new_payment = await create_payment( create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet, wallet_id=settings.lnbits_service_fee_wallet,
fee=0,
amount=abs(service_fee_msat),
memo="Service fee",
checking_id="service_fee" + temp_id,
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
pending=False, amount=abs(service_fee_msat),
memo="Service fee",
)
new_payment = await create_payment(
checking_id=f"service_fee_{temp_id}",
data=create_payment_model,
status=PaymentState.SUCCESS,
) )
return invoice.payment_hash return invoice.payment_hash
async def _create_external_payment(
temp_id: str,
amount_msat: MilliSatoshi,
data: CreatePayment,
conn: Optional[Connection],
) -> Payment:
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(temp_id, conn=conn)
if old_payment:
# fail on pending payments
if old_payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if old_payment.success:
raise PaymentError("Payment already paid.", status="success")
if old_payment.failed:
status = await old_payment.check_status()
if status.success:
# payment was successful on the fundingsource
old_payment.status = PaymentState.SUCCESS
await update_payment(old_payment, conn=conn)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
# status.pending fall through and try again
return old_payment
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
data.fee = -abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=temp_id,
data=data,
conn=conn,
)
return new_payment
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment", status="failed") from exc
def _check_wallet_balance( def _check_wallet_balance(
wallet: Wallet, wallet: Wallet,
fee_reserve_total_msat: int, fee_reserve_total_msat: int,
internal_checking_id: Optional[str] = None, internal_payment: Optional[Payment] = None,
): ):
if wallet.balance_msat < 0: if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment") logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_total_msat: if not internal_payment and wallet.balance_msat > -fee_reserve_total_msat:
raise PaymentError( raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}" f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.", " sat) to cover potential routing fees.",
@@ -445,8 +483,10 @@ async def check_time_limit_between_transactions(conn, wallet_id):
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat): async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
limit = settings.lnbits_wallet_limit_daily_max_withdraw limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit or limit <= 0: if not limit:
return return
if limit < 0:
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments( payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24, since=int(time.time()) - 60 * 60 * 24,
@@ -474,7 +514,7 @@ async def redeem_lnurl_withdraw(
wallet_id: str, wallet_id: str,
lnurl_request: str, lnurl_request: str,
memo: Optional[str] = None, memo: Optional[str] = None,
extra: Optional[Dict] = None, extra: Optional[dict] = None,
wait_seconds: int = 0, wait_seconds: int = 0,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
@@ -612,12 +652,11 @@ async def check_transaction_status(
) )
if not payment: if not payment:
return PaymentPendingStatus() return PaymentPendingStatus()
if not payment.pending:
# note: before, we still checked the status of the payment again if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee) return PaymentSuccessStatus(fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status() return await payment.check_status()
return status
# WARN: this same value must be used for balance check and passed to # WARN: this same value must be used for balance check and passed to
@@ -666,20 +705,23 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
async def update_wallet_balance(wallet_id: str, amount: int): async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
)
async with db.connect() as conn: async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn) payment_hash, _ = await create_invoice(
assert checking_id, "newly created checking_id cannot be retrieved" wallet_id=wallet_id,
await update_payment_status(checking_id=checking_id, pending=False, conn=conn) amount=amount,
memo="Admin top up",
internal=True,
conn=conn,
)
internal_payment = await check_internal(payment_hash, conn=conn)
assert internal_payment, "newly created checking_id cannot be retrieved"
internal_payment.status = PaymentState.SUCCESS
await update_payment(internal_payment, conn=conn)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(checking_id) await internal_invoice_queue.put(internal_payment.checking_id)
async def check_admin_settings(): async def check_admin_settings():
@@ -713,7 +755,7 @@ async def check_admin_settings():
send_admin_user_to_saas() send_admin_user_to_saas()
account = await get_account(settings.super_user) account = await get_account(settings.super_user)
if account and account.config and account.config.provider == "env": if account and account.extra and account.extra.provider == "env":
settings.first_install = True settings.first_install = True
logger.success( logger.success(
@@ -764,20 +806,58 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
if super_user: if super_user:
account = await get_account(super_user) account = await get_account(super_user)
if not account: if not account:
account = await create_account( account_id = super_user or uuid4().hex
user_id=super_user, user_config=UserConfig(provider="env") account = Account(
id=account_id,
extra=UserExtra(provider="env"),
) )
if not account.wallets or len(account.wallets) == 0: await create_account(account)
await create_wallet(user_id=account.id) await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict()) editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict()) return await create_admin_settings(account.id, editable_settings.dict())
async def create_user_account(
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
if account:
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email):
raise ValueError("Email already exists.")
if account.pubkey and await get_account_by_pubkey(account.pubkey):
raise ValueError("Pubkey already exists.")
if account.id:
user_uuid4 = UUID(hex=account.id, version=4)
assert user_uuid4.hex == account.id, "User ID is not valid UUID4 hex string"
else:
account.id = uuid4().hex
account = await create_account(account)
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
for ext_id in settings.lnbits_user_default_extensions:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
user = await get_user(account)
assert user, "Cannot find user for account."
return user
class WebsocketConnectionManager: class WebsocketConnectionManager:
def __init__(self) -> None: def __init__(self) -> None:
self.active_connections: List[WebSocket] = [] self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str): async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}") logger.debug(f"Websocket connected to {item_id}")
@@ -816,3 +896,19 @@ async def get_balance_delta() -> BalanceDelta:
lnbits_balance_msats=lnbits_balance, lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat, node_balance_msats=status.balance_msat,
) )
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
elif status.success:
payment.status = PaymentState.SUCCESS
await update_payment(payment)
+36 -2
View File
@@ -24,6 +24,40 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('nostr-auth-nip98')"
class="q-pl-xl"
>
<strong class="q-my-none q-mb-sm">Nostr Auth</strong>
<div class="row">
<div class="col-md-12 col-sm-12 q-pr-sm">
<q-input
filled
v-model="nostrAcceptedUrl"
@keydown.enter="addNostrUrl"
type="text"
label="Nostr Request URL"
hint="Absolute URL that the clients will use to login."
>
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
</q-input>
</div>
<div>
<div>
<q-chip
v-for="url in formData.nostr_absolute_request_urls"
:key="url"
removable
@remove="removeNostrUrl(url)"
color="primary"
text-color="white"
:label="url"
></q-chip>
</div>
</div>
</div>
</q-card-section>
<q-card-section <q-card-section
v-if="formData.auth_allowed_methods?.includes('google-auth')" v-if="formData.auth_allowed_methods?.includes('google-auth')"
class="q-pl-xl" class="q-pl-xl"
@@ -173,7 +207,7 @@
@remove="removeBlockedIPs(blocked_ip)" @remove="removeBlockedIPs(blocked_ip)"
color="primary" color="primary"
text-color="white" text-color="white"
v-text="blocked_ip" :label="blocked_ip"
></q-chip> ></q-chip>
</div> </div>
<br /> <br />
@@ -202,7 +236,7 @@
@remove="removeAllowedIPs(allowed_ip)" @remove="removeAllowedIPs(allowed_ip)"
color="primary" color="primary"
text-color="white" text-color="white"
v-text="allowed_ip" :label="allowed_ip"
></q-chip> ></q-chip>
</div> </div>
<br /> <br />
+95 -87
View File
@@ -1,24 +1,19 @@
<q-tab-panel name="server"> <q-tab-panel name="server">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none">Server Management</h6> <h6 class="q-my-none">Server Management</h6>
<br />
<div> <div>
<div class="row"> <div class="row">
<div class="col"> <div class="col-md-6">
<p>Server Info</p> <p>Base URL</p>
<ul> <q-input
<li filled
v-if="settings.lnbits_data_folder" v-model.number="formData.lnbits_baseurl"
v-text="'SQlite: ' + settings.lnbits_data_folder" label="Static/Base url for the server"
></li> ></q-input>
<li
v-if="settings.lnbits_database_url"
v-text="'Postgres: ' + settings.lnbits_database_url"
></li>
</ul>
<br /> <br />
</div> </div>
</div> </div>
<h6 class="q-my-none">Currency Settings</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p>Allowed currencies</p> <p>Allowed currencies</p>
@@ -45,56 +40,7 @@
<br /> <br />
</div> </div>
</div> </div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
<br /> <br />
<h6 class="q-my-none">Service Fee</h6> <h6 class="q-my-none">Service Fee</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@@ -154,32 +100,94 @@
<br /> <br />
</div> </div>
</div> </div>
<q-separator></q-separator>
<h6 class="q-my-none">Extensions</h6> <h6 class="q-my-none">Extensions</h6>
<div> <div class="row q-col-gutter-md">
<p>Extension Sources</p> <div class="col-12">
<q-input <p>Extension Sources</p>
filled <q-input
v-model="formAddExtensionsManifest" filled
@keydown.enter="addExtensionsManifest" v-model="formAddExtensionsManifest"
type="text" @keydown.enter="addExtensionsManifest"
label="Source URL (only use the official LNbits extension source, and sources you can trust)" type="text"
hint="Repositories from where the extensions can be downloaded" label="Source URL (only use the official LNbits extension source, and sources you can trust)"
> hint="Repositories from where the extensions can be downloaded"
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn> >
</q-input> <q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
<div> </q-input>
<q-chip <div>
v-for="manifestUrl in formData.lnbits_extensions_manifests" <q-chip
:key="manifestUrl" v-for="manifestUrl in formData.lnbits_extensions_manifests"
removable :key="manifestUrl"
@remove="removeExtensionsManifest(manifestUrl)" removable
color="primary" @remove="removeExtensionsManifest(manifestUrl)"
text-color="white" color="primary"
><span v-text="manifestUrl"></span text-color="white"
></q-chip> ><span v-text="manifestUrl"></span
></q-chip>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>User Default Extensions</p>
<q-select
filled
v-model="formData.lnbits_user_default_extensions"
multiple
hint="Extensions that will be enabled by default for the users."
label="User extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div> </div>
<br />
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
+2 -2
View File
@@ -23,8 +23,8 @@
@remove="removeAdminUser(user)" @remove="removeAdminUser(user)"
color="primary" color="primary"
text-color="white" text-color="white"
:label="user"
> >
<span v-text="user"></span>
</q-chip> </q-chip>
</div> </div>
<br /> <br />
@@ -49,8 +49,8 @@
@remove="removeAllowedUser(user)" @remove="removeAllowedUser(user)"
color="primary" color="primary"
text-color="white" text-color="white"
:label="user"
> >
<span v-text="user" />
</q-chip> </q-chip>
</div> </div>
<br /> <br />
+1
View File
@@ -26,6 +26,7 @@
:label="$t('restart')" :label="$t('restart')"
color="primary" color="primary"
@click="restartServer" @click="restartServer"
class="q-ml-md"
> >
<q-tooltip v-if="needsRestart"> <q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span> <span v-text="$t('restart_tooltip')"></span>
+72 -9
View File
@@ -5,10 +5,75 @@
:content-inset-level="0.5" :content-inset-level="0.5"
> >
<q-card-section> <q-card-section>
<strong>Node URL: </strong><em v-text="origin"></em><br /> <q-list>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br /> <q-item dense class="q-pa-none">
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br /> <q-item-section>
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em> <q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText(wallet.id)"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText('{{ wallet.adminkey }}')"
></q-icon>
</div>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="inkeyHidden = !inkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.inkey)"
></q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section> </q-card-section>
<q-expansion-item <q-expansion-item
group="api" group="api"
@@ -109,18 +174,16 @@
><span class="text-light-green">POST</span> ><span class="text-light-green">POST</span>
/api/v1/payments/decode</code /api/v1/payments/decode</code
> >
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"invoice": &lt;string&gt;}</code> <code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none"> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json) Returns 200 (application/json)
</h5> </h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d >curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "X-Api-Key: '{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code application/json"</code
> >
</q-card-section> </q-card-section>
</q-card> </q-card>
+87 -27
View File
@@ -26,7 +26,9 @@
</q-tabs> </q-tabs>
<q-tab-panels v-model="tab"> <q-tab-panels v-model="tab">
<q-tab-panel name="user"> <q-tab-panel name="user">
<div v-if="passwordData.show"> <div v-if="credentialsData.show">
<q-separator></q-separator>
<q-card-section> <q-card-section>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@@ -36,19 +38,26 @@
</div> </div>
<div class="col"> <div class="col">
<q-img <q-img
v-if="user.config.picture" v-if="user.extra.picture"
style="max-width: 100px" style="max-width: 100px"
:src="user.config.picture" :src="user.extra.picture"
class="float-right" class="float-right"
></q-img> ></q-img>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
<q-separator></q-separator>
<q-card-section> <q-card-section>
<q-input
v-model="credentialsData.username"
:label="$t('username')"
filled
dense
:readonly="hasUsername"
class="q-mb-md"
></q-input>
<q-input <q-input
v-if="user.has_password" v-if="user.has_password"
v-model="passwordData.oldPassword" v-model="credentialsData.oldPassword"
type="password" type="password"
autocomplete="off" autocomplete="off"
label="Old Password" label="Old Password"
@@ -57,7 +66,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]" :rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input> ></q-input>
<q-input <q-input
v-model="passwordData.newPassword" v-model="credentialsData.newPassword"
type="password" type="password"
autocomplete="off" autocomplete="off"
:label="$t('password')" :label="$t('password')"
@@ -66,7 +75,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]" :rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input> ></q-input>
<q-input <q-input
v-model="passwordData.newPasswordRepeat" v-model="credentialsData.newPasswordRepeat"
type="password" type="password"
autocomplete="off" autocomplete="off"
:label="$t('password_repeat')" :label="$t('password_repeat')"
@@ -75,24 +84,47 @@
class="q-mb-md" class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]" :rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input> ></q-input>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pb-lg">
<q-btn <q-btn
@click="updatePassword" @click="updatePassword"
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat" :disable="disableUpdatePassword()"
unelevated unelevated
color="primary" color="primary"
class="float-right"
:label="$t('change_password')" :label="$t('change_password')"
> >
</q-btn> </q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section>
<div class="col q-mb-sm">
<h4 class="q-my-none">
<span v-text="$t('pubkey')"></span>
</h4>
</div>
<q-input
v-model="credentialsData.pubkey"
type="text"
label="Pubkey"
filled
dense
></q-input>
<q-btn <q-btn
@click="passwordData.show = false" @click="updatePubkey"
unelevated
color="primary"
class="q-mt-md float-right"
:label="$t('update_pubkey')"
>
</q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="credentialsData.show = false"
:label="$t('back')" :label="$t('back')"
outline outline
unelevated unelevated
color="grey" color="grey"
class="float-right"
></q-btn> ></q-btn>
</q-card-section> </q-card-section>
</div> </div>
@@ -101,9 +133,9 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<q-img <q-img
v-if="user.config.picture" v-if="user.extra.picture"
style="max-width: 100px" style="max-width: 100px"
:src="user.config.picture" :src="user.extra.picture"
class="float-right" class="float-right"
></q-img> ></q-img>
</div> </div>
@@ -137,6 +169,15 @@
class="q-mb-md" class="q-mb-md"
> >
</q-input> </q-input>
<q-input
v-model="user.pubkey"
:label="$t('pubkey')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input <q-input
v-model="user.email" v-model="user.email"
:label="$t('email')" :label="$t('email')"
@@ -195,9 +236,9 @@
</div> </div>
</q-card-section> </q-card-section>
<q-card-section v-if="user.config"> <q-card-section v-if="user.extra">
<q-input <q-input
v-model="user.config.first_name" v-model="user.extra.first_name"
:label="$t('first_name')" :label="$t('first_name')"
filled filled
dense dense
@@ -205,7 +246,7 @@
> >
</q-input> </q-input>
<q-input <q-input
v-model="user.config.last_name" v-model="user.extra.last_name"
:label="$t('last_name')" :label="$t('last_name')"
filled filled
dense dense
@@ -213,7 +254,7 @@
> >
</q-input> </q-input>
<q-input <q-input
v-model="user.config.provider" v-model="user.extra.provider"
:label="$t('auth_provider')" :label="$t('auth_provider')"
filled filled
dense dense
@@ -222,10 +263,9 @@
> >
</q-input> </q-input>
<q-input <q-input
v-model="user.config.picture" v-model="user.extra.picture"
:label="$t('picture')" :label="$t('picture')"
filled filled
dense
class="q-mb-md" class="q-mb-md"
> >
</q-input> </q-input>
@@ -236,11 +276,10 @@
<span v-text="$t('update_account')"></span> <span v-text="$t('update_account')"></span>
</q-btn> </q-btn>
<q-btn <q-btn
@click="showChangePassword()" @click="showUpdateCredentials()"
:label="user.has_password ? $t('change_password'): $t('set_password')" :label="$t('update_credentials')"
outline filled
unelevated color="primary"
color="grey"
class="float-right" class="float-right"
></q-btn> ></q-btn>
</q-card-section> </q-card-section>
@@ -373,6 +412,27 @@
</q-btn> </q-btn>
</div> </div>
</div> </div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('gradient_background')"></span>
</div>
<div class="col-8">
<q-btn
dense
flat
round
@click="toggleGradient"
icon="gradient"
size="sm"
v-model="gradientChoice"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
></q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mb-md"> <div class="row q-mb-md">
<div class="col-4"> <div class="col-4">
<span v-text="$t('toggle_darkmode')"></span> <span v-text="$t('toggle_darkmode')"></span>
@@ -410,7 +470,7 @@
v-model="reactionChoice" v-model="reactionChoice"
:options="reactionOptions" :options="reactionOptions"
label="Reactions" label="Reactions"
@input="reactionChoiceFunc" @update:model-value="reactionChoiceFunc"
> >
<q-tooltip <q-tooltip
><span v-text="$t('payment_reactions')"></span ><span v-text="$t('payment_reactions')"></span
File diff suppressed because it is too large Load Diff
@@ -97,9 +97,9 @@
} }
</style> </style>
<script> <script>
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
loginData: { loginData: {
+102 -3
View File
@@ -33,7 +33,7 @@
color="primary" color="primary"
@click="processing" @click="processing"
type="a" type="a"
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}" href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')" v-text="$t('press_to_claim')"
class="full-width" class="full-width"
></q-btn> ></q-btn>
@@ -184,6 +184,47 @@
</div> </div>
</q-form> </q-form>
</q-card-section> </q-card-section>
<q-card-section
v-if="authAction === 'reset' && authMethod === 'username-password'"
>
<b> <span v-text="$t('reset_password')"></span> </b><br /><br />
<q-form @submit="reset" class="q-gutter-md">
<q-input
filled
dense
required
:disable="true"
v-model="reset_key"
:label="$t('reset_key') + ' *'"
></q-input>
<q-input
filled
dense
v-model="password"
:label="$t('password') + ' *'"
type="password"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
filled
dense
v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'"
type="password"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<div>
<q-btn
unelevated
color="primary"
:disable="!password || !passwordRepeat|| !reset_key || (password !== passwordRepeat)"
type="submit"
class="full-width"
:label="$t('reset_password')"
></q-btn>
</div>
</q-form>
</q-card-section>
{%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %} {%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
<q-card-section <q-card-section
v-if="authAction === 'register' && authMethod === 'user-id-only'" v-if="authAction === 'register' && authMethod === 'user-id-only'"
@@ -230,7 +271,28 @@
v-if="authAction === 'login' && authMethod === 'username-password'" v-if="authAction === 'login' && authMethod === 'username-password'"
> >
<div class="row"> <div class="row">
{% if "google-auth" in LNBITS_AUTH_METHODS %} {% if "nostr-auth-nip98" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
@click="signInWithNostr"
outline
no-caps
rounded
color="grey"
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
class="bg-primary"
:src="'{{ static_url_for('static', 'images/logos/nostr.svg') }}'"
></q-img>
</q-avatar>
<div>
<span v-text="$t('signin_with_nostr')"></span>
</div>
</q-btn>
</div>
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm"> <div class="col-12 full-width q-pa-sm">
<q-btn <q-btn
href="/api/v1/auth/google" href="/api/v1/auth/google"
@@ -484,6 +546,32 @@
</a> </a>
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<a <a
@@ -519,7 +607,18 @@
></q-img> ></q-img>
</a> </a>
</div> </div>
<div class="col q-pl-md"></div> <div class="col">
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
+19 -19
View File
@@ -6,7 +6,7 @@
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script> <script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
{% endblock %} {% endblock %}
<!----> <!---->
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %} {% block title %}{{ wallet_name }} - {{ SITE_TITLE }} {% endblock %}
<!----> <!---->
{% block page %} {% block page %}
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@@ -38,7 +38,6 @@
<strong v-text="formattedBalance"></strong> <strong v-text="formattedBalance"></strong>
<small>{{LNBITS_DENOMINATION}}</small> <small>{{LNBITS_DENOMINATION}}</small>
<lnbits-update-balance <lnbits-update-balance
v-if="'{{user.super_user}}' == 'True'"
:wallet_id="this.g.wallet.id" :wallet_id="this.g.wallet.id"
flat flat
:callback="updateBalanceCallback" :callback="updateBalanceCallback"
@@ -154,11 +153,16 @@
<q-card> <q-card>
<q-card-section class="text-center"> <q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p> <p v-text="$t('export_to_phone_desc')"></p>
<qrcode <lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
></qrcode>
</q-card-section> </q-card-section>
<q-card-actions class="flex-center q-pb-md">
<q-btn
outline
color="grey"
:label="$t('copy_wallet_url')"
@click="copyText('{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}')"
></q-btn>
</q-card-actions>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-separator></q-separator> <q-separator></q-separator>
@@ -311,13 +315,11 @@
<q-input <q-input
ref="setAmount" ref="setAmount"
filled filled
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
inputmode="numeric"
dense dense
v-model.number="receive.data.amount" v-model.number="receive.data.amount"
:label="$t('amount') + ' (' + receive.unit + ') *'" :label="$t('amount') + ' (' + receive.unit + ') *'"
:mask="receive.unit != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="receive.unit != 'sat' ? '0.01' : '1'"
:min="receive.minMax[0]" :min="receive.minMax[0]"
:max="receive.minMax[1]" :max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed" :readonly="receive.lnurl && receive.lnurl.fixed"
@@ -364,11 +366,9 @@
> >
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq"> <a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl"> <lnbits-qrcode
<lnbits-qrcode :value="'lightning:' + receive.paymentReq.toUpperCase()"
:value="'lightning:' + receive.paymentReq.toUpperCase()" ></lnbits-qrcode>
></lnbits-qrcode>
</q-responsive>
</a> </a>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
@@ -621,8 +621,8 @@
<div v-else> <div v-else>
<q-responsive :ratio="1"> <q-responsive :ratio="1">
<qrcode-stream <qrcode-stream
@decode="decodeQR" @detect="decodeQR"
@init="onInitQR" @camera-on="onInitQR"
class="rounded-borders" class="rounded-borders"
></qrcode-stream> ></qrcode-stream>
</q-responsive> </q-responsive>
@@ -645,8 +645,8 @@
<q-card class="q-pa-lg q-pt-xl"> <q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<qrcode-stream <qrcode-stream
@decode="decodeQR" @detect="decodeQR"
@init="onInitQR" @camera-on="onInitQR"
class="rounded-borders" class="rounded-borders"
></qrcode-stream> ></qrcode-stream>
</div> </div>
@@ -51,11 +51,11 @@
> >
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11"> <a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
<qrcode <qrcode-vue
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()" :value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}" :options="{width: 340}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode-vue>
</q-responsive> </q-responsive>
</a> </a>
<q-btn <q-btn
@@ -94,9 +94,9 @@
<q-table <q-table
dense dense
flat flat
:data="paymentsTable.data" :rows="paymentsTable.data"
:columns="paymentsTable.columns" :columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination" v-model:pagination="paymentsTable.pagination"
row-key="payment_hash" row-key="payment_hash"
no-data-label="No transactions made yet" no-data-label="No transactions made yet"
:filter="paymentsTable.filter" :filter="paymentsTable.filter"
@@ -138,11 +138,11 @@
> >
<a :href="'lightning:' + props.row.bolt11"> <a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
<qrcode <qrcode-vue
:value="'lightning:' + props.row.bolt11.toUpperCase()" :value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}" :options="{width: 340}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode-vue>
</q-responsive> </q-responsive>
</a> </a>
</div> </div>
@@ -256,9 +256,9 @@
<q-table <q-table
dense dense
flat flat
:data="invoiceTable.data" :rows="invoiceTable.data"
:columns="invoiceTable.columns" :columns="invoiceTable.columns"
:pagination.sync="invoiceTable.pagination" v-model:pagination="invoiceTable.pagination"
no-data-label="No transactions made yet" no-data-label="No transactions made yet"
:filter="invoiceTable.filter" :filter="invoiceTable.filter"
@request="getInvoices" @request="getInvoices"
+3 -3
View File
@@ -47,7 +47,7 @@
Vue.component(VueQrcode.name, VueQrcode) Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader) Vue.use(VueQrcodeReader)
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
config: { config: {
globalProperties: { globalProperties: {
@@ -55,7 +55,7 @@
msg: 'hello' msg: 'hello'
} }
}, },
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
isSuperUser: false, isSuperUser: false,
@@ -319,7 +319,7 @@
.confirmDialog('Do you really wanna disconnect this peer?') .confirmDialog('Do you really wanna disconnect this peer?')
.onOk(() => { .onOk(() => {
this.api('DELETE', `/peers/${id}`).then(response => { this.api('DELETE', `/peers/${id}`).then(response => {
this.$q.notify({ Quasar.Notify.create({
message: 'Disconnected', message: 'Disconnected',
icon: null icon: null
}) })
+2 -5
View File
@@ -53,10 +53,7 @@ context %} {% block page %}
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/node.js') }}"></script> <script src="{{ static_url_for('static', 'js/node.js') }}"></script>
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) window.app = Vue.createApp({
Vue.use(VueQrcodeReader)
new Vue({
el: '#vue', el: '#vue',
config: { config: {
globalProperties: { globalProperties: {
@@ -64,7 +61,7 @@ context %} {% block page %}
msg: 'hello' msg: 'hello'
} }
}, },
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
isSuperUser: false, isSuperUser: false,
@@ -6,7 +6,7 @@
<payment-list :wallet="activeWallet" /> <payment-list :wallet="activeWallet" />
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-table :data="wallets" :columns="walletTable.columns"> <q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
@@ -37,6 +37,7 @@
icon="content_copy" icon="content_copy"
size="sm" size="sm"
color="primary" color="primary"
class="q-ml-xs"
@click="copyText(props.row.id)" @click="copyText(props.row.id)"
> >
<q-tooltip>Copy Wallet ID</q-tooltip> <q-tooltip>Copy Wallet ID</q-tooltip>
@@ -45,6 +46,7 @@
v-if="!props.row.deleted" v-if="!props.row.deleted"
:wallet_id="props.row.id" :wallet_id="props.row.id"
:callback="topupCallback" :callback="topupCallback"
class="q-ml-xs"
></lnbits-update-balance> ></lnbits-update-balance>
<q-btn <q-btn
round round
@@ -52,6 +54,7 @@
icon="vpn_key" icon="vpn_key"
size="sm" size="sm"
color="primary" color="primary"
class="q-ml-xs"
@click="copyText(props.row.adminkey)" @click="copyText(props.row.adminkey)"
> >
<q-tooltip>Copy Admin Key</q-tooltip> <q-tooltip>Copy Admin Key</q-tooltip>
@@ -62,6 +65,7 @@
icon="vpn_key" icon="vpn_key"
size="sm" size="sm"
color="secondary" color="secondary"
class="q-ml-xs"
@click="copyText(props.row.inkey)" @click="copyText(props.row.inkey)"
> >
<q-tooltip>Copy Invoice Key</q-tooltip> <q-tooltip>Copy Invoice Key</q-tooltip>
@@ -72,6 +76,7 @@
icon="toggle_off" icon="toggle_off"
size="sm" size="sm"
color="secondary" color="secondary"
class="q-ml-xs"
@click="undeleteUserWallet(props.row.user, props.row.id)" @click="undeleteUserWallet(props.row.user, props.row.id)"
> >
<q-tooltip>Undelete Wallet</q-tooltip> <q-tooltip>Undelete Wallet</q-tooltip>
@@ -81,6 +86,7 @@
icon="delete" icon="delete"
size="sm" size="sm"
color="negative" color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)" @click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
> >
<q-tooltip>Delete Wallet</q-tooltip> <q-tooltip>Delete Wallet</q-tooltip>
+22 -6
View File
@@ -7,7 +7,7 @@ include "users/_createWalletDialog.html" %}
<div class="row q-col-gutter-md justify-center"> <div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md" style="width: 300px"> <div class="col q-gutter-y-md" style="width: 300px">
<div style="width: 600px"> <div style="width: 100%; max-width: 2000px">
<canvas ref="chart1"></canvas> <canvas ref="chart1"></canvas>
</div> </div>
</div> </div>
@@ -24,10 +24,10 @@ include "users/_createWalletDialog.html" %}
</q-btn> </q-btn>
</div> </div>
<q-table <q-table
:data="users" row-key="id"
:row-key="usersTableRowKey" :rows="users"
:columns="usersTable.columns" :columns="usersTable.columns"
:pagination.sync="usersTable.pagination" v-model:pagination="usersTable.pagination"
:no-data-label="$t('no_users')" :no-data-label="$t('no_users')"
:filter="usersTable.search" :filter="usersTable.search"
:loading="usersTable.loading" :loading="usersTable.loading"
@@ -61,6 +61,7 @@ include "users/_createWalletDialog.html" %}
icon="content_copy" icon="content_copy"
size="sm" size="sm"
color="primary" color="primary"
class="q-ml-xs"
@click="copyText(props.row.id)" @click="copyText(props.row.id)"
> >
<q-tooltip>Copy User ID</q-tooltip> <q-tooltip>Copy User ID</q-tooltip>
@@ -70,7 +71,8 @@ include "users/_createWalletDialog.html" %}
v-if="!props.row.is_super_user" v-if="!props.row.is_super_user"
icon="build" icon="build"
size="sm" size="sm"
:color="props.row.is_admin ? 'primary' : ''" :color="props.row.is_admin ? 'primary' : 'grey'"
class="q-ml-xs"
@click="toggleAdmin(props.row.id)" @click="toggleAdmin(props.row.id)"
> >
<q-tooltip>Toggle Admin</q-tooltip> <q-tooltip>Toggle Admin</q-tooltip>
@@ -81,14 +83,25 @@ include "users/_createWalletDialog.html" %}
icon="build" icon="build"
size="sm" size="sm"
color="positive" color="positive"
class="q-ml-xs"
> >
<q-tooltip>Super User</q-tooltip> <q-tooltip>Super User</q-tooltip>
</q-btn> </q-btn>
<q-btn
round
icon="refresh"
size="sm"
color="secondary"
@click="resetPassword(props.row.id)"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn <q-btn
round round
icon="delete" icon="delete"
size="sm" size="sm"
color="negative" color="negative"
class="q-ml-xs"
@click="deleteUser(props.row.id, props)" @click="deleteUser(props.row.id, props)"
> >
<q-tooltip>Delete User</q-tooltip> <q-tooltip>Delete User</q-tooltip>
@@ -102,7 +115,10 @@ include "users/_createWalletDialog.html" %}
<q-td auto-width v-text="props.row.transaction_count"></q-td> <q-td auto-width v-text="props.row.transaction_count"></q-td>
<q-td auto-width v-text="props.row.username"></q-td> <q-td auto-width v-text="props.row.username"></q-td>
<q-td auto-width v-text="props.row.email"></q-td> <q-td auto-width v-text="props.row.email"></q-td>
<q-td auto-width v-text="props.row.last_payment"></q-td> <q-td
auto-width
v-text="formatDate(props.row.last_payment)"
></q-td>
</q-tr> </q-tr>
</template> </template>
</q-table> </q-table>
+25 -26
View File
@@ -2,7 +2,7 @@ import hashlib
import json import json
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
from typing import Dict, List from time import time
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx import httpx
@@ -12,7 +12,7 @@ from fastapi import (
Depends, Depends,
) )
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from starlette.responses import StreamingResponse from fastapi.responses import StreamingResponse
from lnbits.core.models import ( from lnbits.core.models import (
BaseWallet, BaseWallet,
@@ -33,45 +33,38 @@ from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
allowed_currencies, allowed_currencies,
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
get_fiat_rate_satoshis,
satoshis_amount_as_fiat, satoshis_amount_as_fiat,
) )
from ..crud import ( from ..services import create_user_account, perform_lnurlauth
create_account,
create_wallet,
)
from ..services import perform_lnurlauth
# backwards compatibility for extension
# TODO: remove api_payment and pay_invoice imports from extensions
from .payment_api import api_payment, pay_invoice # noqa: F401
api_router = APIRouter(tags=["Core"]) api_router = APIRouter(tags=["Core"])
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK) @api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
async def health(): async def health() -> dict:
return return {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
"version": settings.version,
}
@api_router.get( @api_router.get(
"/api/v1/wallets", "/api/v1/wallets",
name="Wallets", name="Wallets",
description="Get basic info for all of user's 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[BaseWallet]: async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
return [BaseWallet(**w.dict()) for w in user.wallets] return user.wallets
@api_router.post("/api/v1/account", response_model=Wallet) @api_router.post("/api/v1/account")
async def api_create_account(data: CreateWallet) -> Wallet: async def api_create_account(data: CreateWallet) -> Wallet:
if not settings.new_accounts_allowed: user = await create_user_account(wallet_name=data.name)
raise HTTPException( return user.wallets[0]
status_code=HTTPStatus.FORBIDDEN,
detail="Account creation is disabled.",
)
account = await create_account()
return await create_wallet(user_id=account.id, wallet_name=data.name)
@api_router.get("/api/v1/lnurlscan/{code}") @api_router.get("/api/v1/lnurlscan/{code}")
@@ -99,7 +92,7 @@ async def api_lnurlscan(
) from exc ) from exc
# params is what will be returned to the client # params is what will be returned to the client
params: Dict = {"domain": domain} params: dict = {"domain": domain}
if "tag=login" in url: if "tag=login" in url:
params.update(kind="auth") params.update(kind="auth")
@@ -148,7 +141,7 @@ async def api_lnurlscan(
# callback with k1 already in it # callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"]) parsed_callback: ParseResult = urlparse(data["callback"])
qs: Dict = parse_qs(parsed_callback.query) qs: dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"] qs["k1"] = data["k1"]
# balanceCheck/balanceNotify # balanceCheck/balanceNotify
@@ -204,8 +197,14 @@ async def api_perform_lnurlauth(
return "" return ""
@api_router.get("/api/v1/rate/{currency}")
async def api_check_fiat_rate(currency: str) -> dict[str, float]:
rate = await get_fiat_rate_satoshis(currency)
return {"rate": rate}
@api_router.get("/api/v1/currencies") @api_router.get("/api/v1/currencies")
async def api_list_currencies_available() -> List[str]: async def api_list_currencies_available() -> list[str]:
return allowed_currencies() return allowed_currencies()
+258 -138
View File
@@ -1,18 +1,18 @@
import base64
import importlib import importlib
import json
from http import HTTPStatus
from time import time
from typing import Callable, Optional from typing import Callable, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger from loguru import logger
from starlette.status import (
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_500_INTERNAL_SERVER_ERROR,
)
from lnbits.decorators import check_user_exists from lnbits.core.services import create_user_account
from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
decrypt_internal_message, decrypt_internal_message,
@@ -21,27 +21,30 @@ from lnbits.helpers import (
is_valid_username, is_valid_username,
) )
from lnbits.settings import AuthMethods, settings from lnbits.settings import AuthMethods, settings
from lnbits.utils.nostr import normalize_public_key, verify_event
from ..crud import ( from ..crud import (
create_account,
create_user,
get_account, get_account,
get_account_by_email, get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email, get_account_by_username_or_email,
get_user, get_user,
update_account, update_account,
update_user_password,
verify_user_password,
) )
from ..models import ( from ..models import (
AccessTokenPayload,
Account,
CreateUser, CreateUser,
LoginUsernamePassword, LoginUsernamePassword,
LoginUsr, LoginUsr,
ResetUserPassword,
UpdateSuperuserPassword, UpdateSuperuserPassword,
UpdateUser, UpdateUser,
UpdateUserPassword, UpdateUserPassword,
UpdateUserPubkey,
User, User,
UserConfig, UserExtra,
) )
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"]) auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
@@ -56,41 +59,43 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
async def login(data: LoginUsernamePassword) -> JSONResponse: async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password): if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException( raise HTTPException(
HTTP_401_UNAUTHORIZED, "Login by 'Username and Password' not allowed." HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
) )
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)
try:
user = await get_account_by_username_or_email(data.username)
if not user: @auth_router.post("/nostr", description="Login via Nostr")
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.") async def nostr_login(request: Request) -> JSONResponse:
if not await verify_user_password(user.id, data.password): if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.") raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
return _auth_success_response(user.username, user.id) )
except HTTPException as exc: event = _nostr_nip98_event(request)
raise exc account = await get_account_by_pubkey(event["pubkey"])
except Exception as exc: if not account:
logger.debug(exc) account = Account(
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc id=uuid4().hex,
pubkey=event["pubkey"],
extra=UserExtra(provider="nostr"),
)
await create_user_account(account)
return _auth_success_response(account.username or "", account.id, account.email)
@auth_router.post("/usr", description="Login via the User ID") @auth_router.post("/usr", description="Login via the User ID")
async def login_usr(data: LoginUsr) -> JSONResponse: async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only): if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.") raise HTTPException(
HTTPStatus.UNAUTHORIZED,
try: "Login by 'User ID' not allowed.",
user = await get_user(data.usr) )
if not user: account = await get_account(data.usr)
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.") if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(user.username or "", user.id) return _auth_success_response(account.username, account.id, account.email)
except HTTPException as exc:
raise exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.get("/{provider}", description="SSO Provider") @auth_router.get("/{provider}", description="SSO Provider")
@@ -100,7 +105,8 @@ async def login_with_sso_provider(
provider_sso = _new_sso(provider) provider_sso = _new_sso(provider)
if not provider_sso: if not provider_sso:
raise HTTPException( raise HTTPException(
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed." HTTPStatus.UNAUTHORIZED,
f"Login by '{provider}' not allowed.",
) )
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token" provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
@@ -114,31 +120,22 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
provider_sso = _new_sso(provider) provider_sso = _new_sso(provider)
if not provider_sso: if not provider_sso:
raise HTTPException( raise HTTPException(
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed." HTTPStatus.UNAUTHORIZED,
f"Login by '{provider}' not allowed.",
) )
try: with provider_sso:
with provider_sso: userinfo = await provider_sso.verify_and_process(request)
userinfo = await provider_sso.verify_and_process(request) if not userinfo:
assert userinfo is not None raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
user_id = decrypt_internal_message(provider_sso.state) user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None) request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id) return await _handle_sso_login(userinfo, user_id)
except HTTPException as exc:
raise exc
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR,
f"Cannot authenticate user with {provider} Auth.",
) from exc
@auth_router.post("/logout") @auth_router.post("/logout")
async def logout() -> JSONResponse: async def logout() -> JSONResponse:
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK) response = JSONResponse({"status": "success"}, HTTPStatus.OK)
response.delete_cookie("cookie_access_token") response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized") response.delete_cookie("is_lnbits_user_authorized")
response.delete_cookie("is_access_token_expired") response.delete_cookie("is_access_token_expired")
@@ -151,53 +148,124 @@ async def logout() -> JSONResponse:
async def register(data: CreateUser) -> JSONResponse: async def register(data: CreateUser) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password): if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException( raise HTTPException(
HTTP_401_UNAUTHORIZED, "Register by 'Username and Password' not allowed." HTTPStatus.UNAUTHORIZED,
"Register by 'Username and Password' not allowed.",
) )
if data.password != data.password_repeat: if data.password != data.password_repeat:
raise HTTPException(HTTP_400_BAD_REQUEST, "Passwords do not match.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Passwords do not match.")
if not data.username: if not data.username:
raise HTTPException(HTTP_400_BAD_REQUEST, "Missing username.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Missing username.")
if not is_valid_username(data.username): if not is_valid_username(data.username):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if await get_account_by_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email): if data.email and not is_valid_email_address(data.email):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
try: account = Account(
user = await create_user(data) id=uuid4().hex,
return _auth_success_response(user.username) email=data.email,
username=data.username,
)
account.hash_password(data.password)
await create_user_account(account)
return _auth_success_response(account.username, account.id, account.email)
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc @auth_router.put("/pubkey")
except Exception as exc: async def update_pubkey(
logger.debug(exc) data: UpdateUserPubkey,
raise HTTPException( user: User = Depends(check_user_exists),
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user." payload: AccessTokenPayload = Depends(access_token_payload),
) from exc ) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
_validate_auth_timeout(payload.auth_time)
if (
data.pubkey
and data.pubkey != user.pubkey
and await get_account_by_pubkey(data.pubkey)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "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(account)
@auth_router.put("/password") @auth_router.put("/password")
async def update_password( async def update_password(
data: UpdateUserPassword, user: User = Depends(check_user_exists) data: UpdateUserPassword,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]: ) -> Optional[User]:
_validate_auth_timeout(payload.auth_time)
assert data.user_id == user.id, "Invalid user ID."
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
assert account, "Account not found."
# old accounts do not have a password
if account.password_hash:
assert data.password_old, "Missing old password."
assert account.verify_password(data.password_old), "Invalid old password."
account.username = data.username
account.hash_password(data.password)
await update_account(account)
_user = await get_user(account)
if not _user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return _user
@auth_router.put("/reset")
async def reset_password(data: ResetUserPassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password): if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException( raise HTTPException(
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed." HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
) )
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.") assert data.password == data.password_repeat, "Passwords do not match."
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
try: try:
return await update_user_password(data) reset_key = base64.b64decode(data.reset_key[10:]).decode()
except AssertionError as exc: reset_data_json = decrypt_internal_message(reset_key)
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc: except Exception as exc:
logger.debug(exc) raise ValueError("Invalid reset key.") from exc
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password." assert reset_data_json, "Cannot process reset key."
) from exc
action, user_id, request_time = json.loads(reset_data_json)
assert action, "Missing action."
assert user_id, "Missing user ID."
assert request_time, "Missing reset time."
_validate_auth_timeout(request_time)
account = await get_account(user_id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
account.hash_password(data.password)
await update_account(account)
return _auth_success_response(account.username, user_id, account.email)
@auth_router.put("/update") @auth_router.put("/update")
@@ -205,80 +273,83 @@ async def update(
data: UpdateUser, user: User = Depends(check_user_exists) data: UpdateUser, user: User = Depends(check_user_exists)
) -> Optional[User]: ) -> Optional[User]:
if data.user_id != user.id: if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username): if data.username and not is_valid_username(data.username):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if data.email != user.email: if data.email != user.email:
raise HTTPException(HTTP_400_BAD_REQUEST, "Email mismatch.")
try:
return await update_account(user.id, data.username, None, data.config)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException( raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user." HTTPStatus.BAD_REQUEST,
) from exc "Email mismatch.",
)
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if (
data.email
and data.email != user.email
and await get_account_by_email(data.email)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Email already exists.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
if data.username:
account.username = data.username
if data.email:
account.email = data.email
if data.extra:
account.extra = data.extra
await update_account(account)
return await get_user(account)
@auth_router.put("/first_install") @auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse: async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install: if not settings.first_install:
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install") raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
try: account = await get_account(settings.super_user)
await update_account( if not account:
user_id=settings.super_user, raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
username=data.username, account.username = data.username
user_config=UserConfig(provider="lnbits"), account.extra = account.extra or UserExtra()
) account.extra.provider = "lnbits"
super_user = UpdateUserPassword( account.hash_password(data.password)
user_id=settings.super_user, await update_account(account)
password=data.password, settings.first_install = False
password_repeat=data.password_repeat, return _auth_success_response(account.username, account.id, account.email)
username=data.username,
)
await update_user_password(super_user)
settings.first_install = False
return _auth_success_response(username=super_user.username)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
) from exc
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None): async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
email = userinfo.email email = userinfo.email
if not email or not is_valid_email_address(email): if not email or not is_valid_email_address(email):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet" redirect_path = "/wallet"
user_config = UserConfig(**dict(userinfo))
user_config.email_verified = True
account = await get_account_by_email(email) account = await get_account_by_email(email)
if verified_user_id: if verified_user_id:
if account: if account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Email already used.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
account = await get_account(verified_user_id) account = await get_account(verified_user_id)
if not account: if not account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Cannot verify user email.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
redirect_path = "/account" redirect_path = "/account"
if account: if account:
user = await update_account(account.id, email=email, user_config=user_config) account.extra = account.extra or UserExtra()
account.extra.email_verified = True
await update_account(account)
else: else:
if not settings.new_accounts_allowed: account = Account(
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.") id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
user = await create_account(email=email, user_config=user_config) )
await create_user_account(account)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
return _auth_redirect_response(redirect_path, email) return _auth_redirect_response(redirect_path, email)
@@ -287,9 +358,10 @@ def _auth_success_response(
user_id: Optional[str] = None, user_id: Optional[str] = None,
email: Optional[str] = None, email: Optional[str] = None,
) -> JSONResponse: ) -> JSONResponse:
access_token = create_access_token( payload = AccessTokenPayload(
data={"sub": username or "", "usr": user_id, "email": email} sub=username or "", usr=user_id, email=email, auth_time=int(time())
) )
access_token = create_access_token(data=payload.dict())
response = JSONResponse({"access_token": access_token, "token_type": "bearer"}) response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie("cookie_access_token", access_token, httponly=True) response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true") response.set_cookie("is_lnbits_user_authorized", "true")
@@ -299,7 +371,8 @@ def _auth_success_response(
def _auth_redirect_response(path: str, email: str) -> RedirectResponse: def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
access_token = create_access_token(data={"sub": "" or "", "email": email}) payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
access_token = create_access_token(data=payload.dict())
response = RedirectResponse(path) response = RedirectResponse(path)
response.set_cookie("cookie_access_token", access_token, httponly=True) response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true") response.set_cookie("is_lnbits_user_authorized", "true")
@@ -348,3 +421,50 @@ def _find_auth_provider_class(provider: str) -> Callable:
pass pass
raise ValueError(f"No SSO provider found for '{provider}'.") raise ValueError(f"No SSO provider found for '{provider}'.")
def _nostr_nip98_event(request: Request) -> dict:
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Nostr Auth header missing.")
scheme, token = auth_header.split()
if scheme.lower() != "nostr":
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
event = None
try:
event_json = base64.b64decode(token.encode("ascii"))
event = json.loads(event_json)
except Exception as exc:
logger.warning(exc)
assert event, "Nostr login event cannot be parsed."
if not verify_event(event):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
assert event["kind"] == 27_235, "Invalid event kind."
auth_threshold = settings.auth_credetials_update_threshold
assert (
abs(time() - event["created_at"]) < auth_threshold
), f"More than {auth_threshold} seconds have passed since the event was signed."
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
assert method, "Tag 'method' is missing."
assert method.upper() == "POST", "Invalid value for tag 'method'."
url = next((v for k, v in event["tags"] if k == "u"), None)
assert url, "Tag 'u' for URL is missing."
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'."
return event
def _validate_auth_timeout(auth_time: Optional[int] = 0):
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"You can only update your credentials in the first"
f" {settings.auth_credetials_update_threshold} seconds."
" Please login again or ask a new reset key!",
)
+190 -181
View File
@@ -1,9 +1,4 @@
import sys
from http import HTTPStatus from http import HTTPStatus
from typing import (
List,
Optional,
)
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from fastapi import ( from fastapi import (
@@ -13,10 +8,23 @@ from fastapi import (
) )
from loguru import logger from loguru import logger
from lnbits.core.db import core_app_extra from lnbits.core.extensions.extension_manager import (
from lnbits.core.helpers import ( activate_extension,
migrate_extension_database, deactivate_extension,
stop_extension_background_work, install_extension,
uninstall_extension,
)
from lnbits.core.extensions.models import (
CreateExtension,
Extension,
ExtensionConfig,
ExtensionMeta,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtension,
UserExtensionInfo,
) )
from lnbits.core.models import ( from lnbits.core.models import (
SimpleStatus, SimpleStatus,
@@ -24,37 +32,20 @@ from lnbits.core.models import (
) )
from lnbits.core.services import check_transaction_status, create_invoice from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import ( from lnbits.decorators import (
check_access_token,
check_admin, check_admin,
check_user_exists, check_user_exists,
) )
from lnbits.extension_manager import (
CreateExtension,
Extension,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
fetch_github_release_config,
fetch_release_payment_info,
get_valid_extensions,
)
from lnbits.settings import settings
from ..crud import ( from ..crud import (
add_installed_extension, create_user_extension,
delete_dbversion, delete_dbversion,
delete_installed_extension,
drop_extension_db, drop_extension_db,
get_dbversions, get_db_version,
get_installed_extension, get_installed_extension,
get_installed_extensions, get_installed_extensions,
get_user_extension, get_user_extension,
update_extension_pay_to_enable, update_installed_extension,
update_installed_extension_state,
update_user_extension, update_user_extension,
update_user_extension_extra,
) )
extension_router = APIRouter( extension_router = APIRouter(
@@ -63,12 +54,8 @@ extension_router = APIRouter(
) )
@extension_router.post("") @extension_router.post("", dependencies=[Depends(check_admin)])
async def api_install_extension( async def api_install_extension(data: CreateExtension):
data: CreateExtension,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
release = await InstallableExtension.get_extension_release( release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version data.ext_id, data.source_repo, data.archive, data.version
) )
@@ -83,50 +70,72 @@ async def api_install_extension(
) )
release.payment_hash = data.payment_hash release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension( ext_info = InstallableExtension(
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon id=data.ext_id,
name=data.ext_id,
version=data.version,
meta=ext_meta,
icon=release.icon,
) )
try: try:
installed_ext = await get_installed_extension(data.ext_id) extension = await install_extension(ext_info)
ext_info.payments = installed_ext.payments if installed_ext else []
await ext_info.download_archive()
ext_info.extract_archive()
extension = Extension.from_installable_ext(ext_info)
db_version = (await get_dbversions()).get(data.ext_id, 0)
await migrate_extension_database(extension, db_version)
ext_info.active = True
await add_installed_extension(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(data.ext_id, user.id, access_token)
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
ext_info.notify_upgrade(extension.upgrade_hash)
settings.lnbits_deactivated_extensions.discard(data.ext_id)
return extension
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
ext_info.clean_extension_files() ext_info.clean_extension_files()
detail = (
str(exc)
if isinstance(exc, AssertionError)
else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})."
)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=( detail=detail,
f"Failed to install extension {ext_info.id} "
f"({ext_info.installed_version})."
),
) from exc ) from exc
try:
await activate_extension(extension)
return extension
except Exception as exc:
logger.warning(exc)
await deactivate_extension(extension.code)
detail = (
str(exc)
if isinstance(exc, AssertionError)
else f"Extension `{extension.code}` installed, but activation failed."
)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=detail,
) from exc
@extension_router.get("/{ext_id}/details")
async def api_extension_details(
ext_id: str,
details_link: str,
):
all_releases = await InstallableExtension.get_extension_releases(ext_id)
release = next((r for r in all_releases if r.details_link == details_link), None)
if not release:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
)
release_details = await ExtensionRelease.fetch_release_details(details_link)
if not release_details:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch details for release",
)
release_details["icon"] = release.icon
release_details["repo"] = release.repo
return release_details
@extension_router.put("/{ext_id}/sell") @extension_router.put("/{ext_id}/sell")
async def api_update_pay_to_enable( async def api_update_pay_to_enable(
@@ -134,29 +143,28 @@ async def api_update_pay_to_enable(
data: PayToEnableInfo, data: PayToEnableInfo,
user: User = Depends(check_admin), user: User = Depends(check_admin),
) -> SimpleStatus: ) -> SimpleStatus:
try: if data.wallet not in user.wallet_ids:
assert (
data.wallet in user.wallet_ids
), "Wallet does not belong to this admin user."
await update_extension_pay_to_enable(ext_id, data)
return SimpleStatus(
success=True, message=f"Payment info updated for '{ext_id}' extension."
)
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
detail=(f"Failed to update pay to install data for extension '{ext_id}' "), )
) from exc extension = await get_installed_extension(ext_id)
if not extension:
raise HTTPException(HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' not found.")
if extension.meta:
extension.meta.pay_to_enable = data
else:
extension.meta = ExtensionMeta(pay_to_enable=data)
await update_installed_extension(extension)
return SimpleStatus(
success=True, message=f"Payment info updated for '{ext_id}' extension."
)
@extension_router.put("/{ext_id}/enable") @extension_router.put("/{ext_id}/enable")
async def api_enable_extension( async def api_enable_extension(
ext_id: str, user: User = Depends(check_user_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]: if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
raise HTTPException( raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist." HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
) )
@@ -166,28 +174,34 @@ async def api_enable_extension(
assert ext, f"Extension '{ext_id}' is not installed." assert ext, f"Extension '{ext_id}' is not installed."
assert ext.active, f"Extension '{ext_id}' is not activated." assert ext.active, f"Extension '{ext_id}' is not activated."
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)
await create_user_extension(user_ext)
if user.admin or not ext.requires_payment: if user.admin or not ext.requires_payment:
await update_user_extension(user_id=user.id, extension=ext_id, active=True) user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
user_ext = await get_user_extension(user.id, ext_id) if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
if not (user_ext and user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException( raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment." HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
) )
if user_ext.is_paid: if user_ext.is_paid:
await update_user_extension(user_id=user.id, extension=ext_id, active=True) user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus( return SimpleStatus(
success=True, message=f"Paid extension '{ext_id}' enabled." success=True, message=f"Paid extension '{ext_id}' enabled."
) )
assert ( assert (
ext.pay_to_enable and ext.pay_to_enable.wallet ext.meta and ext.meta.pay_to_enable and ext.meta.pay_to_enable.wallet
), f"Extension '{ext_id}' is missing payment wallet." ), f"Extension '{ext_id}' is missing payment wallet."
payment_status = await check_transaction_status( payment_status = await check_transaction_status(
wallet_id=ext.pay_to_enable.wallet, wallet_id=ext.meta.pay_to_enable.wallet,
payment_hash=user_ext.extra.payment_hash_to_enable, payment_hash=user_ext.extra.payment_hash_to_enable,
) )
@@ -197,10 +211,9 @@ async def api_enable_extension(
f"Invoice generated but not paid for enabeling extension '{ext_id}'.", f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
) )
user_ext.active = True
user_ext.extra.paid_to_enable = True user_ext.extra.paid_to_enable = True
await update_user_extension_extra(user.id, ext_id, user_ext.extra) await update_user_extension(user_ext)
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
except AssertionError as exc: except AssertionError as exc:
@@ -219,20 +232,19 @@ async def api_enable_extension(
async def api_disable_extension( async def api_disable_extension(
ext_id: str, user: User = Depends(check_user_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]: if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist." HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
) )
try: user_ext = await get_user_extension(user.id, ext_id)
logger.info(f"Disabeling extension: {ext_id}.") if not user_ext or not user_ext.active:
await update_user_extension(user_id=user.id, extension=ext_id, active=False) return SimpleStatus(
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.") success=True, message=f"Extension '{ext_id}' already disabled."
except Exception as exc: )
logger.warning(exc) logger.info(f"Disabeling extension: {ext_id}.")
raise HTTPException( user_ext.active = False
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, await update_user_extension(user_ext)
detail=(f"Failed to disable '{ext_id}'."), return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
) from exc
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)]) @extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
@@ -240,20 +252,14 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
try: try:
logger.info(f"Activating extension: '{ext_id}'.") logger.info(f"Activating extension: '{ext_id}'.")
all_extensions = get_valid_extensions() ext = Extension.get_valid_extension(ext_id)
ext = next((e for e in all_extensions if e.code == ext_id), None)
assert ext, f"Extension '{ext_id}' doesn't exist." assert ext, f"Extension '{ext_id}' doesn't exist."
# if extension never loaded (was deactivated on server startup)
if ext_id not in sys.modules.keys():
# run extension start-up routine
core_app_extra.register_new_ext_routes(ext)
settings.lnbits_deactivated_extensions.discard(ext_id) await activate_extension(ext)
await update_installed_extension_state(ext_id=ext_id, active=True)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
await deactivate_extension(ext_id)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to activate '{ext_id}'."), detail=(f"Failed to activate '{ext_id}'."),
@@ -265,13 +271,10 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
try: try:
logger.info(f"Deactivating extension: '{ext_id}'.") logger.info(f"Deactivating extension: '{ext_id}'.")
all_extensions = get_valid_extensions() ext = Extension.get_valid_extension(ext_id)
ext = next((e for e in all_extensions if e.code == ext_id), None)
assert ext, f"Extension '{ext_id}' doesn't exist." assert ext, f"Extension '{ext_id}' doesn't exist."
settings.lnbits_deactivated_extensions.add(ext_id) await deactivate_extension(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
@@ -281,27 +284,27 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
) from exc ) from exc
@extension_router.delete("/{ext_id}") @extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
async def api_uninstall_extension( async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
ext_id: str,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
) -> SimpleStatus:
installed_extensions = await get_installed_extensions()
extensions = [e for e in installed_extensions if e.id == ext_id] extension = await get_installed_extension(ext_id)
if len(extensions) == 0: if not extension:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
detail=f"Unknown extension id: {ext_id}", detail=f"Unknown extension id: {ext_id}",
) )
installed_extensions = await get_installed_extensions()
# check that other extensions do not depend on this one # check that other extensions do not depend on this one
for valid_ext_id in [ext.code for ext in get_valid_extensions()]: for valid_ext_id in [ext.code for ext in Extension.get_valid_extensions()]:
installed_ext = next( installed_ext = next(
(ext for ext in installed_extensions if ext.id == valid_ext_id), None (ext for ext in installed_extensions if ext.id == valid_ext_id), None
) )
if installed_ext and ext_id in installed_ext.dependencies: if (
installed_ext
and installed_ext.meta
and ext_id in installed_ext.meta.dependencies
):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=( detail=(
@@ -311,14 +314,7 @@ async def api_uninstall_extension(
) )
try: try:
# call stop while the old routes are still active await uninstall_extension(ext_id)
await stop_extension_background_work(ext_id, user.id, access_token)
settings.lnbits_deactivated_extensions.add(ext_id)
for ext_info in extensions:
ext_info.clean_extension_files()
await delete_installed_extension(ext_id=ext_info.id)
logger.success(f"Extension '{ext_id}' uninstalled.") logger.success(f"Extension '{ext_id}' uninstalled.")
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
@@ -329,9 +325,9 @@ async def api_uninstall_extension(
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)]) @extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
async def get_extension_releases(ext_id: str) -> List[ExtensionRelease]: async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
try: try:
extension_releases: List[ExtensionRelease] = ( extension_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id) await InstallableExtension.get_extension_releases(ext_id)
) )
@@ -367,9 +363,8 @@ async def get_pay_to_install_invoice(
assert release, "Release not found." assert release, "Release not found."
assert release.pay_link, "Pay link not found for release." assert release.pay_link, "Pay link not found for release."
payment_info = await fetch_release_payment_info( payment_info = await release.fetch_release_payment_info(data.cost_sats)
release.pay_link, data.cost_sats
)
assert payment_info and payment_info.payment_request, "Cannot request invoice." assert payment_info and payment_info.payment_request, "Cannot request invoice."
invoice = bolt11_decode(payment_info.payment_request) invoice = bolt11_decode(payment_info.payment_request)
@@ -397,45 +392,59 @@ async def get_pay_to_install_invoice(
async def get_pay_to_enable_invoice( async def get_pay_to_enable_invoice(
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists) ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
): ):
try: if not data.amount or data.amount <= 0:
assert data.amount and data.amount > 0, "A non-zero amount must be specified."
ext = await get_installed_extension(ext_id)
assert ext, f"Extension '{ext_id}' not found."
assert ext.pay_to_enable, f"Payment Info not found for extension '{ext_id}'."
assert (
ext.pay_to_enable.required
), f"Payment not required for extension '{ext_id}'."
assert ext.pay_to_enable.wallet and ext.pay_to_enable.amount, (
f"Payment wallet or amount missing for extension '{ext_id}'."
"Please contact the administrator."
)
assert (
data.amount >= ext.pay_to_enable.amount
), f"Minimum amount is {ext.pay_to_enable.amount} sats."
payment_hash, payment_request = await create_invoice(
wallet_id=ext.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
)
user_ext = await get_user_extension(user.id, ext_id)
user_ext_info = (
user_ext.extra if user_ext and user_ext.extra else UserExtensionInfo()
)
user_ext_info.payment_hash_to_enable = payment_hash
await update_user_extension_extra(user.id, ext_id, user_ext_info)
return {"payment_hash": payment_hash, "payment_request": payment_request}
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException( raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice." status_code=HTTPStatus.BAD_REQUEST, detail="Amount must be greater than 0."
) from exc )
ext = await get_installed_extension(ext_id)
if not ext:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail=f"Extension '{ext_id}' not found."
)
if not ext.meta or not ext.meta.pay_to_enable:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Payment info not found for extension '{ext_id}'.",
)
if not ext.meta.pay_to_enable.required:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Payment not required for extension '{ext_id}'.",
)
if not ext.meta.pay_to_enable.wallet or not ext.meta.pay_to_enable.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Payment wallet or amount missing for extension '{ext_id}'.",
)
if data.amount < ext.meta.pay_to_enable.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Amount {data.amount} sats is less than required "
f"{ext.meta.pay_to_enable.amount} sats."
),
)
payment_hash, payment_request = await create_invoice(
wallet_id=ext.meta.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
)
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)
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_hash
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return {"payment_hash": payment_hash, "payment_request": payment_request}
@extension_router.get( @extension_router.get(
@@ -444,7 +453,7 @@ async def get_pay_to_enable_invoice(
) )
async def get_extension_release(org: str, repo: str, tag_name: str): async def get_extension_release(org: str, repo: str, tag_name: str):
try: try:
config = await fetch_github_release_config(org, repo, tag_name) config = await ExtensionConfig.fetch_github_release_config(org, repo, tag_name)
if not config: if not config:
return {} return {}
@@ -465,7 +474,7 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
) )
async def delete_extension_db(ext_id: str): async def delete_extension_db(ext_id: str):
try: try:
db_version = (await get_dbversions()).get(ext_id, None) db_version = await get_db_version(ext_id)
if not db_version: if not db_version:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
+147 -93
View File
@@ -1,29 +1,32 @@
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import Annotated, List, Optional, Union from typing import Annotated, List, Optional, Union
from urllib.parse import urlparse from urllib.parse import urlencode, urlparse
import httpx
from fastapi import Cookie, Depends, Query, Request from fastapi import Cookie, Depends, Query, Request
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from loguru import logger from lnurl import decode as lnurl_decode
from pydantic.types import UUID4 from pydantic.types import UUID4
from lnbits.core.extensions.models import Extension, ExtensionMeta, InstallableExtension
from lnbits.core.helpers import to_valid_user_id from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.core.services import create_invoice, create_user_account
from lnbits.decorators import check_admin, check_user_exists from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from ...extension_manager import InstallableExtension, get_valid_extensions
from ...utils.exchange_rates import allowed_currencies, currencies from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import ( from ..crud import (
create_wallet, create_wallet,
get_dbversions, get_db_versions,
get_installed_extensions, get_installed_extensions,
get_user, get_user_by_id,
get_wallet,
) )
generic_router = APIRouter( generic_router = APIRouter(
@@ -70,83 +73,87 @@ async def robots():
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse) @generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)): async def extensions(request: Request, user: User = Depends(check_user_exists)):
try: installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts: List["InstallableExtension"] = await get_installed_extensions() installed_exts_ids = [e.id for e in installed_exts]
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions() installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts] installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [ installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
e for e in installed_exts if e.id not in installable_exts_ids
]
for e in installable_exts: for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None) installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext: if installed_ext and installed_ext.meta:
e.installed_release = installed_ext.installed_release installed_release = installed_ext.meta.installed_release
if installed_ext.pay_to_enable and not user.admin: if installed_ext.meta.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id # not a security leak, but better not to share the wallet id
installed_ext.pay_to_enable.wallet = None installed_ext.meta.pay_to_enable.wallet = None
e.pay_to_enable = installed_ext.pay_to_enable pay_to_enable = installed_ext.meta.pay_to_enable
# use the installed extension values if e.meta:
e.name = installed_ext.name e.meta.installed_release = installed_release
e.short_description = installed_ext.short_description e.meta.pay_to_enable = pay_to_enable
e.icon = installed_ext.icon else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
except Exception as ex: all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
logger.warning(ex) inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
installable_exts = [] db_versions = await get_db_versions()
installed_exts_ids = []
try: extensions = [
all_ext_ids = [ext.code for ext in get_valid_extensions()] {
inactive_extensions = [ "id": ext.id,
e.id for e in await get_installed_extensions(active=False) "name": ext.name,
] "icon": ext.icon,
db_version = await get_dbversions() "shortDescription": ext.short_description,
extensions = [ "stars": ext.stars,
{ "isFeatured": ext.meta.featured if ext.meta else False,
"id": ext.id, "dependencies": ext.meta.dependencies if ext.meta else "",
"name": ext.name, "isInstalled": ext.id in installed_exts_ids,
"icon": ext.icon, "hasDatabaseTables": next(
"shortDescription": ext.short_description, (True for version in db_versions if version.db == ext.id), False
"stars": ext.stars, ),
"isFeatured": ext.featured, "isAvailable": ext.id in all_ext_ids,
"dependencies": ext.dependencies, "isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isInstalled": ext.id in installed_exts_ids, "isActive": ext.id not in inactive_extensions,
"hasDatabaseTables": ext.id in db_version, "latestRelease": (
"isAvailable": ext.id in all_ext_ids, dict(ext.meta.latest_release)
"isAdminOnly": ext.id in settings.lnbits_admin_extensions, if ext.meta and ext.meta.latest_release
"isActive": ext.id not in inactive_extensions, else None
"latestRelease": ( ),
dict(ext.latest_release) if ext.latest_release else None "installedRelease": (
), dict(ext.meta.installed_release)
"installedRelease": ( if ext.meta and ext.meta.installed_release
dict(ext.installed_release) if ext.installed_release else None else None
), ),
"payToEnable": (dict(ext.pay_to_enable) if ext.pay_to_enable else {}), "payToEnable": (
"isPaymentRequired": ext.requires_payment, dict(ext.meta.pay_to_enable)
} if ext.meta and ext.meta.pay_to_enable
for ext in installable_exts else {}
] ),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
# refresh user state. Eg: enabled extensions. # refresh user state. Eg: enabled extensions.
user = await get_user(user.id) or user # TODO: refactor
# user = await get_user(user.id) or user
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, request,
"core/extensions.html", "core/extensions.html",
{ {
"user": user.dict(), "user": user.json(),
"extensions": extensions, "extensions": extensions,
}, },
) )
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
@generic_router.get( @generic_router.get(
@@ -161,18 +168,16 @@ async def wallet(
wal: Optional[UUID4] = Query(None), wal: Optional[UUID4] = Query(None),
): ):
if wal: if wal:
wallet_id = wal.hex wallet = await get_wallet(wal.hex)
elif len(user.wallets) == 0: elif len(user.wallets) == 0:
wallet = await create_wallet(user_id=user.id) wallet = await create_wallet(user_id=user.id)
user = await get_user(user_id=user.id) or user user.wallets.append(wallet)
wallet_id = wallet.id
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet): elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
wallet_id = lnbits_last_active_wallet wallet = await get_wallet(lnbits_last_active_wallet)
else: else:
wallet_id = user.wallets[0].id wallet = user.wallets[0]
user_wallet = user.get_wallet(wallet_id) if not wallet or wallet.deleted:
if not user_wallet or user_wallet.deleted:
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
) )
@@ -181,15 +186,16 @@ async def wallet(
request, request,
"core/wallet.html", "core/wallet.html",
{ {
"user": user.dict(), "user": user.json(),
"wallet": user_wallet.dict(), "wallet": wallet.json(),
"wallet_name": wallet.name,
"currencies": allowed_currencies(), "currencies": allowed_currencies(),
"service_fee": settings.lnbits_service_fee, "service_fee": settings.lnbits_service_fee,
"service_fee_max": settings.lnbits_service_fee_max, "service_fee_max": settings.lnbits_service_fee_max,
"web_manifest": f"/manifest/{user.id}.webmanifest", "web_manifest": f"/manifest/{user.id}.webmanifest",
}, },
) )
resp.set_cookie("lnbits_last_active_wallet", wallet_id) resp.set_cookie("lnbits_last_active_wallet", wallet.id)
return resp return resp
@@ -206,7 +212,7 @@ async def account(
request, request,
"core/account.html", "core/account.html",
{ {
"user": user.dict(), "user": user.json(),
}, },
) )
@@ -226,11 +232,9 @@ async def service_worker(request: Request):
@generic_router.get("/manifest/{usr}.webmanifest") @generic_router.get("/manifest/{usr}.webmanifest")
async def manifest(request: Request, usr: str): async def manifest(request: Request, usr: str):
host = urlparse(str(request.url)).netloc host = urlparse(str(request.url)).netloc
user = await get_user_by_id(usr)
user = await get_user(usr)
if not user: if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND) raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return { return {
"short_name": settings.lnbits_site_title, "short_name": settings.lnbits_site_title,
"name": settings.lnbits_site_title + " Wallet", "name": settings.lnbits_site_title + " Wallet",
@@ -318,10 +322,10 @@ async def node(request: Request, user: User = Depends(check_admin)):
request, request,
"node/index.html", "node/index.html",
{ {
"user": user.dict(), "user": user.json(),
"settings": settings.dict(), "settings": settings.dict(),
"balance": balance, "balance": balance,
"wallets": user.wallets[0].dict(), "wallets": user.wallets[0].json(),
}, },
) )
@@ -356,7 +360,7 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
request, request,
"admin/index.html", "admin/index.html",
{ {
"user": user.dict(), "user": user.json(),
"settings": settings.dict(), "settings": settings.dict(),
"balance": balance, "balance": balance,
"currencies": list(currencies.keys()), "currencies": list(currencies.keys()),
@@ -373,7 +377,7 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
"users/index.html", "users/index.html",
{ {
"request": request, "request": request,
"user": user.dict(), "user": user.json(),
"settings": settings.dict(), "settings": settings.dict(),
"currencies": list(currencies.keys()), "currencies": list(currencies.keys()),
}, },
@@ -389,3 +393,53 @@ async def hex_to_uuid4(hex_value: str):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc ) from exc
@generic_router.get("/lnurlwallet", response_class=RedirectResponse)
async def lnurlwallet(request: Request):
"""
If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with
their smartphone camera, or a QR scanner app, they can follow the link provided to
claim their satoshis and get an instant LNbits wallet! lnbits/withdraw docs
"""
lightning_param = request.query_params.get("lightning")
if not lightning_param:
return {"status": "ERROR", "reason": "lightning parameter not provided."}
if not settings.lnbits_allow_new_accounts:
return {"status": "ERROR", "reason": "New accounts are not allowed."}
lnurl = lnurl_decode(lightning_param)
async with httpx.AsyncClient() as client:
res1 = await client.get(lnurl, timeout=2)
res1.raise_for_status()
data1 = res1.json()
if data1.get("tag") != "withdrawRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid lnurl. Expected tag=withdrawRequest",
)
if not data1.get("maxWithdrawable"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid lnurl. Expected maxWithdrawable",
)
account = await create_user_account()
wallet = await create_wallet(user_id=account.id)
_, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=data1.get("maxWithdrawable") / 1000,
memo=data1.get("defaultDescription", "lnurl wallet withdraw"),
)
url = data1.get("callback")
params = {"k1": data1.get("k1"), "pr": payment_request}
callback = url + ("&" if urlparse(url).query else "?") + urlencode(params)
res2 = await client.get(callback, timeout=2)
res2.raise_for_status()
return RedirectResponse(
f"/wallet?usr={account.id}&wal={wallet.id}",
)
+19 -27
View File
@@ -35,12 +35,11 @@ from lnbits.core.models import (
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
get_key_type,
parse_filters, parse_filters,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import generate_filter_params_openapi from lnbits.helpers import filter_dict_keys, generate_filter_params_openapi
from lnbits.lnurl import decode as lnurl_decode from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
@@ -52,13 +51,12 @@ from ..crud import (
get_payments_paginated, get_payments_paginated,
get_standalone_payment, get_standalone_payment,
get_wallet_for_key, get_wallet_for_key,
update_pending_payments,
) )
from ..services import ( from ..services import (
check_transaction_status,
create_invoice, create_invoice,
fee_reserve_total, fee_reserve_total,
pay_invoice, pay_invoice,
update_pending_payments,
) )
from ..tasks import api_invoice_listeners from ..tasks import api_invoice_listeners
@@ -74,12 +72,12 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments( async def api_payments(
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(wallet.wallet.id) await update_pending_payments(key_info.wallet.id)
return await get_payments( return await get_payments(
wallet_id=wallet.wallet.id, wallet_id=key_info.wallet.id,
pending=True, pending=True,
complete=True, complete=True,
filters=filters, filters=filters,
@@ -93,12 +91,12 @@ async def api_payments(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_history( async def api_payments_history(
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
group: DateTrunc = Query("day"), group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(wallet.wallet.id) await update_pending_payments(key_info.wallet.id)
return await get_payments_history(wallet.wallet.id, group, filters) return await get_payments_history(key_info.wallet.id, group, filters)
@payment_router.get( @payment_router.get(
@@ -110,12 +108,12 @@ async def api_payments_history(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_paginated( async def api_payments_paginated(
wallet: WalletTypeInfo = Depends(get_key_type), key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(wallet.wallet.id) await update_pending_payments(key_info.wallet.id)
page = await get_payments_paginated( page = await get_payments_paginated(
wallet_id=wallet.wallet.id, wallet_id=key_info.wallet.id,
pending=True, pending=True,
complete=True, complete=True,
filters=filters, filters=filters,
@@ -379,10 +377,10 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
@payment_router.get("/sse") @payment_router.get("/sse")
async def api_payments_sse( async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type) request: Request, key_info: WalletTypeInfo = Depends(require_invoice_key)
): ):
return EventSourceResponse( return EventSourceResponse(
subscribe_wallet_invoices(request, wallet.wallet), subscribe_wallet_invoices(request, key_info.wallet),
ping=20, ping=20,
media_type="text/event-stream", media_type="text/event-stream",
) )
@@ -402,15 +400,8 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
) )
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment( if payment.success:
payment_hash, wallet_id=wallet.id if wallet else None
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment} return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
@@ -424,12 +415,12 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return { return {
"paid": not payment.pending, "paid": payment.success,
"status": f"{status!s}", "status": f"{status!s}",
"preimage": payment.preimage, "preimage": payment.preimage,
"details": payment, "details": payment,
} }
return {"paid": not payment.pending, "preimage": payment.preimage} return {"paid": payment.success, "preimage": payment.preimage}
@payment_router.post("/decode", status_code=HTTPStatus.OK) @payment_router.post("/decode", status_code=HTTPStatus.OK)
@@ -441,7 +432,8 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
return JSONResponse({"domain": url}) return JSONResponse({"domain": url})
else: else:
invoice = bolt11.decode(payment_str) invoice = bolt11.decode(payment_str)
return JSONResponse(invoice.data) filtered_data = filter_dict_keys(invoice.data, data.filter_fields)
return JSONResponse(filtered_data)
except Exception as exc: except Exception as exc:
return JSONResponse( return JSONResponse(
{"message": f"Failed to decode: {exc!s}"}, {"message": f"Failed to decode: {exc!s}"},
+2 -1
View File
@@ -20,7 +20,8 @@ async def api_public_payment_longpolling(payment_hash):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
) )
elif not payment.pending: # TODO: refactor to use PaymentState
if payment.success:
return {"status": "paid"} return {"status": "paid"}
try: try:
+81 -87
View File
@@ -1,8 +1,11 @@
import base64
import json
import time
from http import HTTPStatus from http import HTTPStatus
from typing import List from typing import List
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from starlette.exceptions import HTTPException from fastapi.exceptions import HTTPException
from lnbits.core.crud import ( from lnbits.core.crud import (
delete_account, delete_account,
@@ -14,8 +17,8 @@ from lnbits.core.crud import (
update_admin_settings, update_admin_settings,
) )
from lnbits.core.models import ( from lnbits.core.models import (
Account,
AccountFilters, AccountFilters,
AccountOverview,
CreateTopup, CreateTopup,
User, User,
Wallet, Wallet,
@@ -23,7 +26,7 @@ from lnbits.core.models import (
from lnbits.core.services import update_wallet_balance from lnbits.core.services import update_wallet_balance
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, check_super_user, parse_filters from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.helpers import generate_filter_params_openapi from lnbits.helpers import encrypt_internal_message, generate_filter_params_openapi
from lnbits.settings import EditableSettings, settings from lnbits.settings import EditableSettings, settings
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)]) users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
@@ -37,106 +40,102 @@ users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_adm
) )
async def api_get_users( async def api_get_users(
filters: Filters = Depends(parse_filters(AccountFilters)), filters: Filters = Depends(parse_filters(AccountFilters)),
) -> Page[Account]: ) -> Page[AccountOverview]:
try: return await get_accounts(filters=filters)
filtered = await get_accounts(filters=filters)
for user in filtered.data:
user.is_super_user = user.id == settings.super_user
user.is_admin = user.id in settings.lnbits_admin_users or user.is_super_user
return filtered
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch users. {exc!s}",
) from exc
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK) @users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
async def api_users_delete_user( async def api_users_delete_user(
user_id: str, user: User = Depends(check_admin) user_id: str, user: User = Depends(check_admin)
) -> None: ) -> None:
wallets = await get_wallets(user_id)
try: if len(wallets) > 0:
wallets = await get_wallets(user_id)
if len(wallets) > 0:
raise Exception("Cannot delete user with wallets.")
if user_id == settings.super_user:
raise Exception("Cannot delete super user.")
if user_id in settings.lnbits_admin_users and not user.super_user:
raise Exception("Only super_user can delete admin user.")
await delete_account(user_id)
except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.BAD_REQUEST,
detail=f"{exc!s}", detail="Cannot delete user with wallets.",
) from exc )
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete super user.",
)
if user_id in settings.lnbits_admin_users and not user.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Only super_user can delete admin user.",
)
await delete_account(user_id)
@users_router.put(
"/user/{user_id}/reset_password", dependencies=[Depends(check_super_user)]
)
async def api_users_reset_password(user_id: str) -> str:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Cannot change superuser password.",
)
reset_data = ["reset", user_id, int(time.time())]
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
reset_key = encrypt_internal_message(reset_data_json)
assert reset_key, "Cannot generate reset key."
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
return f"reset_key_{reset_key_b64}"
@users_router.get("/user/{user_id}/admin", dependencies=[Depends(check_super_user)]) @users_router.get("/user/{user_id}/admin", dependencies=[Depends(check_super_user)])
async def api_users_toggle_admin(user_id: str) -> None: async def api_users_toggle_admin(user_id: str) -> None:
try: if user_id == settings.super_user:
if user_id == settings.super_user:
raise Exception("Cannot change super user.")
if user_id in settings.lnbits_admin_users:
settings.lnbits_admin_users.remove(user_id)
else:
settings.lnbits_admin_users.append(user_id)
update_settings = EditableSettings(
lnbits_admin_users=settings.lnbits_admin_users
)
await update_admin_settings(update_settings)
except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.BAD_REQUEST,
detail=f"Could not update admin settings. {exc}", detail="Cannot change super user.",
) from exc )
if user_id in settings.lnbits_admin_users:
settings.lnbits_admin_users.remove(user_id)
else:
settings.lnbits_admin_users.append(user_id)
update_settings = EditableSettings(lnbits_admin_users=settings.lnbits_admin_users)
await update_admin_settings(update_settings)
@users_router.get("/user/{user_id}/wallet") @users_router.get("/user/{user_id}/wallet")
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]: async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
try: return await get_wallets(user_id)
return await get_wallets(user_id)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch user wallets. {exc}",
) from exc
@users_router.get("/user/{user_id}/wallet/{wallet}/undelete") @users_router.get("/user/{user_id}/wallet/{wallet}/undelete")
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> None: async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> None:
try: wal = await get_wallet(wallet)
wal = await get_wallet(wallet) if not wal:
if not wal:
raise Exception("Wallet does not exist.")
if user_id != wal.user:
raise Exception("Wallet does not belong to user.")
if wal.deleted:
await delete_wallet(user_id=user_id, wallet_id=wallet, deleted=False)
except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.NOT_FOUND,
detail=f"{exc!s}", detail="Wallet does not exist.",
) from exc )
if user_id != wal.user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Wallet does not belong to user.",
)
if wal.deleted:
await delete_wallet(user_id=user_id, wallet_id=wallet, deleted=False)
@users_router.delete("/user/{user_id}/wallet/{wallet}") @users_router.delete("/user/{user_id}/wallet/{wallet}")
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None: async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
try: wal = await get_wallet(wallet)
wal = await get_wallet(wallet) if not wal:
if not wal:
raise Exception("Wallet does not exist.")
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.NOT_FOUND,
detail=f"{exc!s}", detail="Wallet does not exist.",
) from exc )
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
@users_router.put( @users_router.put(
@@ -146,14 +145,9 @@ async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
dependencies=[Depends(check_super_user)], dependencies=[Depends(check_super_user)],
) )
async def api_topup_balance(data: CreateTopup) -> dict[str, str]: async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
try: await get_wallet(data.id)
await get_wallet(data.id) if settings.lnbits_backend_wallet_class == "VoidWallet":
if settings.lnbits_backend_wallet_class == "VoidWallet": raise Exception("VoidWallet active")
raise Exception("VoidWallet active")
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount)) await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return {"status": "Success"} return {"status": "Success"}
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"{exc!s}"
) from exc
+34 -22
View File
@@ -1,9 +1,11 @@
from http import HTTPStatus
from typing import Optional from typing import Optional
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Body, Body,
Depends, Depends,
HTTPException,
) )
from lnbits.core.models import ( from lnbits.core.models import (
@@ -13,13 +15,14 @@ from lnbits.core.models import (
) )
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
get_key_type,
require_admin_key, require_admin_key,
require_invoice_key,
) )
from ..crud import ( from ..crud import (
create_wallet, create_wallet,
delete_wallet, delete_wallet,
get_wallet,
update_wallet, update_wallet,
) )
@@ -27,36 +30,45 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
@wallet_router.get("") @wallet_router.get("")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
if wallet.key_type == KeyType.admin: res = {
return { "name": key_info.wallet.name,
"id": wallet.wallet.id, "balance": key_info.wallet.balance_msat,
"name": wallet.wallet.name, }
"balance": wallet.wallet.balance_msat, if key_info.key_type == KeyType.admin:
} res["id"] = key_info.wallet.id
else: return res
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
@wallet_router.put("/{new_name}") @wallet_router.put("/{new_name}")
async def api_update_wallet_name( async def api_update_wallet_name(
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key) new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
): ):
await update_wallet(wallet.wallet.id, new_name) wallet = await get_wallet(key_info.wallet.id)
if not wallet:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
wallet.name = new_name
await update_wallet(wallet)
return { return {
"id": wallet.wallet.id, "id": wallet.id,
"name": wallet.wallet.name, "name": wallet.name,
"balance": wallet.wallet.balance_msat, "balance": wallet.balance_msat,
} }
@wallet_router.patch("", response_model=Wallet) @wallet_router.patch("")
async def api_update_wallet( async def api_update_wallet(
name: Optional[str] = Body(None), name: Optional[str] = Body(None),
currency: Optional[str] = Body(None), currency: Optional[str] = Body(None),
wallet: WalletTypeInfo = Depends(require_admin_key), key_info: WalletTypeInfo = Depends(require_admin_key),
): ) -> Wallet:
return await update_wallet(wallet.wallet.id, name, currency) wallet = await get_wallet(key_info.wallet.id)
if not wallet:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
wallet.name = name or wallet.name
wallet.currency = currency if currency is not None else wallet.currency
await update_wallet(wallet)
return wallet
@wallet_router.delete("") @wallet_router.delete("")
@@ -69,9 +81,9 @@ async def api_delete_wallet(
) )
@wallet_router.post("", response_model=Wallet) @wallet_router.post("")
async def api_create_wallet( async def api_create_wallet(
data: CreateWallet, data: CreateWallet,
wallet: WalletTypeInfo = Depends(require_admin_key), key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet: ) -> Wallet:
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name) return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
+284 -130
View File
@@ -2,19 +2,19 @@ from __future__ import annotations
import asyncio import asyncio
import datetime import datetime
import json
import os import os
import re import re
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from enum import Enum from enum import Enum
from sqlite3 import Row from typing import Any, Generic, Literal, Optional, TypeVar, Union
from typing import Any, Generic, Literal, Optional, TypeVar
from loguru import logger from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator from pydantic import BaseModel, ValidationError, root_validator
from sqlalchemy import create_engine from sqlalchemy import event
from sqlalchemy_aio.base import AsyncConnection from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY from sqlalchemy.sql import text
from lnbits.settings import settings from lnbits.settings import settings
@@ -24,31 +24,15 @@ SQLITE = "SQLITE"
if settings.lnbits_database_url: if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"): if database_uri.startswith("cockroachdb://"):
DB_TYPE = COCKROACH DB_TYPE = COCKROACH
else: else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' " "format for the database URL."
)
DB_TYPE = POSTGRES DB_TYPE = POSTGRES
from psycopg2.extensions import DECIMAL, new_type, register_type
def _parse_timestamp(value, _):
if value is None:
return None
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
register_type(
new_type(
DECIMAL.values,
"DEC2FLOAT",
lambda value, curs: float(value) if value is not None else None,
)
)
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
else: else:
if not os.path.isdir(settings.lnbits_data_folder): if not os.path.isdir(settings.lnbits_data_folder):
os.mkdir(settings.lnbits_data_folder) os.mkdir(settings.lnbits_data_folder)
@@ -56,13 +40,21 @@ else:
DB_TYPE = SQLITE DB_TYPE = SQLITE
def compat_timestamp_placeholder(): def compat_timestamp_placeholder(key: str):
if DB_TYPE == POSTGRES: if DB_TYPE == POSTGRES:
return "to_timestamp(?)" return f"to_timestamp(:{key})"
elif DB_TYPE == COCKROACH: elif DB_TYPE == COCKROACH:
return "cast(? AS timestamp)" return f"cast(:{key} AS timestamp)"
else: else:
return "?" return f":{key}"
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime.datetime:
return compat_timestamp_placeholder(field)
else:
return f":{field}"
class Compat: class Compat:
@@ -119,15 +111,13 @@ class Compat:
return "BIGINT" return "BIGINT"
return "INT" return "INT"
@property def timestamp_placeholder(self, key: str) -> str:
def timestamp_placeholder(self) -> str: return compat_timestamp_placeholder(key)
return compat_timestamp_placeholder()
class Connection(Compat): class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema): def __init__(self, conn: AsyncConnection, typ, name, schema):
self.conn = conn self.conn = conn
self.txn = txn
self.type = typ self.type = typ
self.name = name self.name = name
self.schema = schema self.schema = schema
@@ -138,49 +128,76 @@ class Connection(Compat):
query = query.replace("?", "%s") query = query.replace("?", "%s")
return query return query
def rewrite_values(self, values): def rewrite_values(self, values: dict) -> dict:
# strip html # strip html
clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});") clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
clean_values: dict = {}
# tuple to list and back to tuple for key, raw_value in values.items():
raw_values = [values] if isinstance(values, str) else list(values)
values = []
for raw_value in raw_values:
if isinstance(raw_value, str): if isinstance(raw_value, str):
values.append(re.sub(clean_regex, "", raw_value)) clean_values[key] = re.sub(clean_regex, "", raw_value)
elif isinstance(raw_value, datetime.datetime): elif isinstance(raw_value, datetime.datetime):
ts = raw_value.timestamp() ts = raw_value.timestamp()
if self.type == SQLITE: if self.type == SQLITE:
values.append(int(ts)) clean_values[key] = int(ts)
else: else:
values.append(ts) clean_values[key] = ts
else: else:
values.append(raw_value) clean_values[key] = raw_value
return tuple(values) return clean_values
async def fetchall(self, query: str, values: tuple = ()) -> list: async def fetchall(
result = await self.conn.execute( self,
self.rewrite_query(query), self.rewrite_values(values) query: str,
) values: Optional[dict] = None,
return await result.fetchall() model: Optional[type[TModel]] = None,
) -> list[TModel]:
async def fetchone(self, query: str, values: tuple = ()): params = self.rewrite_values(values) if values else {}
result = await self.conn.execute( result = await self.conn.execute(text(self.rewrite_query(query)), params)
self.rewrite_query(query), self.rewrite_values(values) row = result.mappings().all()
) result.close()
row = await result.fetchone() if not row:
await result.close() return []
if model:
return [dict_to_model(r, model) for r in row]
return row return row
async def fetchone(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> TModel:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
row = result.mappings().first()
result.close()
if model and row:
return dict_to_model(row, model)
return row
async def update(
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
):
await self.conn.execute(
text(update_query(table_name, model, where)), model_to_dict(model)
)
await self.conn.commit()
async def insert(self, table_name: str, model: BaseModel):
await self.conn.execute(
text(insert_query(table_name, model)), model_to_dict(model)
)
await self.conn.commit()
async def fetch_page( async def fetch_page(
self, self,
query: str, query: str,
where: Optional[list[str]] = None, where: Optional[list[str]] = None,
values: Optional[list[str]] = None, values: Optional[dict] = None,
filters: Optional[Filters] = None, filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None, model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None, group_by: Optional[list[str]] = None,
) -> Page[TRowModel]: ) -> Page[TModel]:
if not filters: if not filters:
filters = Filters() filters = Filters()
clause = filters.where(where) clause = filters.where(where)
@@ -203,14 +220,15 @@ class Connection(Compat):
{filters.order_by()} {filters.order_by()}
{filters.pagination()} {filters.pagination()}
""", """,
parsed_values, self.rewrite_values(parsed_values),
model,
) )
if rows: if rows:
# no need for extra query if no pagination is specified # no need for extra query if no pagination is specified
if filters.offset or filters.limit: if filters.offset or filters.limit:
count = await self.fetchone( result = await self.execute(
f""" f"""
SELECT COUNT(*) FROM ( SELECT COUNT(*) as count FROM (
{query} {query}
{clause} {clause}
{group_by_string} {group_by_string}
@@ -218,21 +236,24 @@ class Connection(Compat):
""", """,
parsed_values, parsed_values,
) )
count = int(count[0]) row = result.mappings().first()
result.close()
count = int(row.get("count", 0))
else: else:
count = len(rows) count = len(rows)
else: else:
count = 0 count = 0
return Page( return Page(
data=[model.from_row(row) for row in rows] if model else rows, data=rows,
total=count, total=count,
) )
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: Optional[dict] = None):
return await self.conn.execute( params = self.rewrite_values(values) if values else {}
self.rewrite_query(query), self.rewrite_values(values) result = await self.conn.execute(text(self.rewrite_query(query)), params)
) await self.conn.commit()
return result
class Database(Compat): class Database(Compat):
@@ -245,18 +266,42 @@ class Database(Compat):
self.path = os.path.join( self.path = os.path.join(
settings.lnbits_data_folder, f"{self.name}.sqlite3" settings.lnbits_data_folder, f"{self.name}.sqlite3"
) )
database_uri = f"sqlite:///{self.path}" database_uri = f"sqlite+aiosqlite:///{self.path}"
else: else:
database_uri = settings.lnbits_database_url database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
if self.name.startswith("ext_"): if self.name.startswith("ext_"):
self.schema = self.name[4:] self.schema = self.name[4:]
else: else:
self.schema = None self.schema = None
self.engine = create_engine( self.engine: AsyncEngine = create_async_engine(
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database 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.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.datetime.strptime(value, f)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime.datetime,
decoder=_parse_date,
schema="pg_catalog",
)
)
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}") logger.trace(f"database {self.type} added for {self.name}")
@@ -265,49 +310,65 @@ class Database(Compat):
async def connect(self): async def connect(self):
await self.lock.acquire() await self.lock.acquire()
try: try:
async with self.engine.connect() as conn: # type: ignore async with self.engine.connect() as conn:
async with conn.begin() as txn: if not conn:
wconn = Connection(conn, txn, self.type, self.name, self.schema) raise Exception("Could not connect to the database")
if self.schema: wconn = Connection(conn, self.type, self.name, self.schema)
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(
f"ATTACH '{self.path}' AS {self.schema}"
)
yield wconn if self.schema:
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(f"ATTACH '{self.path}' AS {self.schema}")
yield wconn
finally: finally:
self.lock.release() self.lock.release()
async def fetchall(self, query: str, values: tuple = ()) -> list: async def fetchall(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> list[TModel]:
async with self.connect() as conn: async with self.connect() as conn:
result = await conn.execute(query, values) return await conn.fetchall(query, values, model)
return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()): async def fetchone(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> TModel:
async with self.connect() as conn: async with self.connect() as conn:
result = await conn.execute(query, values) return await conn.fetchone(query, values, model)
row = await result.fetchone()
await result.close() async def insert(self, table_name: str, model: BaseModel) -> None:
return row async with self.connect() as conn:
await conn.insert(table_name, model)
async def update(
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
) -> None:
async with self.connect() as conn:
await conn.update(table_name, model, where)
async def fetch_page( async def fetch_page(
self, self,
query: str, query: str,
where: Optional[list[str]] = None, where: Optional[list[str]] = None,
values: Optional[list[str]] = None, values: Optional[dict] = None,
filters: Optional[Filters] = None, filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None, model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None, group_by: Optional[list[str]] = None,
) -> Page[TRowModel]: ) -> Page[TModel]:
async with self.connect() as conn: async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by) return await conn.fetch_page(query, where, values, filters, model, group_by)
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: Optional[dict] = None):
async with self.connect() as conn: async with self.connect() as conn:
return await conn.execute(query, values) return await conn.execute(query, values)
@@ -363,12 +424,6 @@ class Operator(Enum):
raise ValueError("Unknown SQL Operator") raise ValueError("Unknown SQL Operator")
class FromRowModel(BaseModel):
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class FilterModel(BaseModel): class FilterModel(BaseModel):
__search_fields__: list[str] = [] __search_fields__: list[str] = []
__sort_fields__: Optional[list[str]] = None __sort_fields__: Optional[list[str]] = None
@@ -376,7 +431,6 @@ class FilterModel(BaseModel):
T = TypeVar("T") T = TypeVar("T")
TModel = TypeVar("TModel", bound=BaseModel) TModel = TypeVar("TModel", bound=BaseModel)
TRowModel = TypeVar("TRowModel", bound=FromRowModel)
TFilterModel = TypeVar("TFilterModel", bound=FilterModel) TFilterModel = TypeVar("TFilterModel", bound=FilterModel)
@@ -388,12 +442,13 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]): class Filter(BaseModel, Generic[TFilterModel]):
field: str field: str
op: Operator = Operator.EQ op: Operator = Operator.EQ
values: list[Any]
model: Optional[type[TFilterModel]] model: Optional[type[TFilterModel]]
values: Optional[dict] = None
@classmethod @classmethod
def parse_query(cls, key: str, raw_values: list[Any], model: type[TFilterModel]): def parse_query(
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
):
# Key format: # Key format:
# key[operator] # key[operator]
# e.g. name[eq] # e.g. name[eq]
@@ -409,12 +464,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__: if field in model.__fields__:
compare_field = model.__fields__[field] compare_field = model.__fields__[field]
values = [] values: dict = {}
for raw_value in raw_values: for raw_value in raw_values:
validated, errors = compare_field.validate(raw_value, {}, loc="none") validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors: if errors:
raise ValidationError(errors=[errors], model=model) raise ValidationError(errors=[errors], model=model)
values.append(validated) values[f"{field}__{i}"] = validated
else: else:
raise ValueError("Unknown filter field") raise ValueError("Unknown filter field")
@@ -422,15 +477,17 @@ class Filter(BaseModel, Generic[TFilterModel]):
@property @property
def statement(self): def statement(self):
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime: stmt = []
placeholder = compat_timestamp_placeholder() for key in self.values.keys() if self.values else []:
else: clean_key = key.split("__")[0]
placeholder = "?" if (
if self.op in (Operator.INCLUDE, Operator.EXCLUDE): self.model
placeholders = ", ".join([placeholder] * len(self.values)) and self.model.__fields__[clean_key].type_ == datetime.datetime
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"] ):
else: placeholder = compat_timestamp_placeholder(key)
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values) else:
placeholder = f":{key}"
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
return " OR ".join(stmt) return " OR ".join(stmt)
@@ -481,14 +538,11 @@ class Filters(BaseModel, Generic[TFilterModel]):
for page_filter in self.filters: for page_filter in self.filters:
where_stmts.append(page_filter.statement) where_stmts.append(page_filter.statement)
if self.search and self.model: if self.search and self.model:
fields = self.model.__search_fields__
if DB_TYPE == POSTGRES: if DB_TYPE == POSTGRES:
where_stmts.append( where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
)
elif DB_TYPE == SQLITE: elif DB_TYPE == SQLITE:
where_stmts.append( where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
f"lower({'||'.join(self.model.__search_fields__)}) LIKE ?"
)
if where_stmts: if where_stmts:
return "WHERE " + " AND ".join(where_stmts) return "WHERE " + " AND ".join(where_stmts)
return "" return ""
@@ -498,12 +552,112 @@ class Filters(BaseModel, Generic[TFilterModel]):
return f"ORDER BY {self.sortby} {self.direction or 'asc'}" return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return "" return ""
def values(self, values: Optional[list[str]] = None) -> tuple: def values(self, values: Optional[dict] = None) -> dict:
if not values: if not values:
values = [] values = {}
if self.filters: if self.filters:
for page_filter in self.filters: for page_filter in self.filters:
values.extend(page_filter.values) if page_filter.values:
for key, value in page_filter.values.items():
values[key] = value
if self.search and self.model: if self.search and self.model:
values.append(f"%{self.search}%") values["search"] = f"%{self.search}%"
return tuple(values) return values
def insert_query(table_name: str, model: BaseModel) -> str:
"""
Generate an insert query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
"""
placeholders = []
keys = model_to_dict(model).keys()
for field in keys:
placeholders.append(get_placeholder(model, 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)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
def update_query(
table_name: str, model: BaseModel, where: str = "WHERE id = :id"
) -> str:
"""
Generate an update query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
:param where: Where string, default to `WHERE id = :id`
"""
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}')
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}"
def model_to_dict(model: BaseModel) -> dict:
"""
Convert a Pydantic model to a dictionary with JSON-encoded nested models
private fields starting with _ are ignored
:param model: Pydantic model
"""
_dict: dict = {}
for key, value in model.dict().items():
type_ = model.__fields__[key].type_
if model.__fields__[key].field_info.extra.get("no_database", False):
continue
if isinstance(value, datetime.datetime):
_dict[key] = value.timestamp()
continue
if type(type_) is type(BaseModel):
_dict[key] = json.dumps(value)
continue
_dict[key] = value
return _dict
def dict_to_submodel(model: type[TModel], value: Union[dict, str]) -> Optional[TModel]:
"""convert a dictionary or JSON string to a Pydantic model"""
if isinstance(value, str):
if value == "null":
return None
_subdict = json.loads(value)
elif isinstance(value, dict):
_subdict = value
else:
logger.warning(f"Expected str or dict, got {type(value)}")
return None
# recursively convert nested models
return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
"""
Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database
:param model: Pydantic model
"""
_dict: dict = {}
for key, value in _row.items():
if key not in model.__fields__:
logger.warning(f"Converting {key} to model `{model}`.")
continue
type_ = model.__fields__[key].type_
if issubclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, BaseModel) and value:
_dict[key] = dict_to_submodel(type_, value)
continue
if type_ is dict and value:
_dict[key] = json.loads(value)
continue
_dict[key] = value
continue
_model = model.construct(**_dict)
return _model
+63 -30
View File
@@ -1,12 +1,12 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated, Literal, Optional, Type, Union from typing import Annotated, Literal, Optional, Type, Union
import jwt
from fastapi import Cookie, Depends, Query, Request, Security from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase from fastapi.security.base import SecurityBase
from jose import ExpiredSignatureError, JWTError, jwt
from loguru import logger from loguru import logger
from pydantic.types import UUID4 from pydantic.types import UUID4
@@ -18,7 +18,14 @@ from lnbits.core.crud import (
get_user_active_extensions_ids, get_user_active_extensions_ids,
get_wallet_for_key, get_wallet_for_key,
) )
from lnbits.core.models import KeyType, SimpleStatus, User, WalletTypeInfo from lnbits.core.models import (
AccessTokenPayload,
Account,
KeyType,
SimpleStatus,
User,
WalletTypeInfo,
)
from lnbits.db import Filter, Filters, TFilterModel from lnbits.db import Filter, Filters, TFilterModel
from lnbits.settings import AuthMethods, settings from lnbits.settings import AuthMethods, settings
@@ -59,7 +66,7 @@ class KeyChecker(SecurityBase):
name="X-API-KEY", name="X-API-KEY",
description="Wallet API Key - HEADER", description="Wallet API Key - HEADER",
) )
self.model: APIKey = openapi_model self.model: APIKey = openapi_model # type: ignore
async def __call__(self, request: Request) -> WalletTypeInfo: async def __call__(self, request: Request) -> WalletTypeInfo:
@@ -95,15 +102,6 @@ class KeyChecker(SecurityBase):
return WalletTypeInfo(key_type, wallet) return WalletTypeInfo(key_type, wallet)
async def get_key_type(
request: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> WalletTypeInfo:
check: KeyChecker = KeyChecker(api_key=api_key_header or api_key_query)
return await check(request)
async def require_admin_key( async def require_admin_key(
request: Request, request: Request,
api_key_header: str = Security(api_key_header), api_key_header: str = Security(api_key_header),
@@ -147,17 +145,42 @@ async def check_user_exists(
else: else:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
if not account or not settings.is_user_allowed(account.id): if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
user = await get_user(account.id) user = await get_user(account)
assert user, "User not found for account." if not user:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
await _check_user_extension_access(user.id, r["path"]) await _check_user_extension_access(user.id, r["path"])
return user return user
async def optional_user_id(
access_token: Annotated[Optional[str], Depends(check_access_token)],
usr: Optional[UUID4] = None,
) -> Optional[str]:
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
return usr.hex
if access_token:
account = await _get_account_from_token(access_token)
return account.id if account else None
return None
async def access_token_payload(
access_token: Annotated[Optional[str], Depends(check_access_token)],
) -> AccessTokenPayload:
if not access_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing access token.")
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
return AccessTokenPayload(**payload)
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User: 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: if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
raise HTTPException( raise HTTPException(
@@ -191,9 +214,9 @@ def parse_filters(model: Type[TFilterModel]):
): ):
params = request.query_params params = request.query_params
filters = [] filters = []
for key in params.keys(): for i, key in enumerate(params.keys()):
try: try:
filters.append(Filter.parse_query(key, params.getlist(key), model)) filters.append(Filter.parse_query(key, params.getlist(key), model, i))
except ValueError: except ValueError:
continue continue
@@ -241,21 +264,31 @@ async def _check_user_extension_access(user_id: str, current_path: str):
) )
async def _get_account_from_token(access_token): async def _get_account_from_token(access_token) -> Optional[Account]:
try: try:
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256") payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
if "sub" in payload and payload.get("sub"): user = await _get_user_from_jwt_payload(payload)
return await get_account_by_username(str(payload.get("sub"))) if not user:
if "usr" in payload and payload.get("usr"): raise HTTPException(
return await get_account(str(payload.get("usr"))) HTTPStatus.UNAUTHORIZED, "Data missing for access token."
if "email" in payload and payload.get("email"): )
return await get_account_by_email(str(payload.get("email")))
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.") return user
except ExpiredSignatureError as exc:
except jwt.ExpiredSignatureError as exc:
raise HTTPException( raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"} HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
) from exc ) from exc
except JWTError as exc: except jwt.PyJWTError as exc:
logger.debug(exc) logger.debug(exc)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
async def _get_user_from_jwt_payload(payload) -> Optional[Account]:
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
return await get_account(str(payload.get("usr")))
if "email" in payload and payload.get("email"):
return await get_account_by_email(str(payload.get("email")))
return None
+30 -18
View File
@@ -8,17 +8,19 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse, Response from fastapi.responses import JSONResponse, RedirectResponse, Response
from loguru import logger from loguru import logger
from lnbits.core.services import InvoiceError, PaymentError
from .helpers import template_renderer from .helpers import template_renderer
def register_exception_handlers(app: FastAPI): class PaymentError(Exception):
register_exception_handler(app) def __init__(self, message: str, status: str = "pending"):
register_request_validation_exception_handler(app) self.message = message
register_http_exception_handler(app) self.status = status
register_payment_error_handler(app)
register_invoice_error_handler(app)
class InvoiceError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
def render_html_error(request: Request, exc: Exception) -> Optional[Response]: def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
@@ -53,7 +55,9 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
return None return None
def register_exception_handler(app: FastAPI): def register_exception_handlers(app: FastAPI):
"""Register exception handlers for the FastAPI app"""
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def exception_handler(request: Request, exc: Exception): async def exception_handler(request: Request, exc: Exception):
etype, _, tb = sys.exc_info() etype, _, tb = sys.exc_info()
@@ -64,8 +68,22 @@ def register_exception_handler(app: FastAPI):
content={"detail": str(exc)}, content={"detail": str(exc)},
) )
@app.exception_handler(AssertionError)
async def assert_error_handler(request: Request, exc: AssertionError):
logger.warning(f"AssertionError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
)
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
logger.warning(f"ValueError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
)
def register_request_validation_exception_handler(app: FastAPI):
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def validation_exception_handler( async def validation_exception_handler(
request: Request, exc: RequestValidationError request: Request, exc: RequestValidationError
@@ -76,8 +94,6 @@ def register_request_validation_exception_handler(app: FastAPI):
content={"detail": str(exc)}, content={"detail": str(exc)},
) )
def register_http_exception_handler(app: FastAPI):
@app.exception_handler(HTTPException) @app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException): async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTPException {exc.status_code}: {exc.detail}") logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
@@ -86,21 +102,17 @@ def register_http_exception_handler(app: FastAPI):
content={"detail": exc.detail}, content={"detail": exc.detail},
) )
def register_payment_error_handler(app: FastAPI):
@app.exception_handler(PaymentError) @app.exception_handler(PaymentError)
async def payment_error_handler(request: Request, exc: PaymentError): async def payment_error_handler(request: Request, exc: PaymentError):
logger.error(f"PaymentError: {exc.message}, {exc.status}") logger.error(f"{exc.message}, {exc.status}")
return JSONResponse( return JSONResponse(
status_code=520, status_code=520,
content={"detail": exc.message, "status": exc.status}, content={"detail": exc.message, "status": exc.status},
) )
def register_invoice_error_handler(app: FastAPI):
@app.exception_handler(InvoiceError) @app.exception_handler(InvoiceError)
async def invoice_error_handler(request: Request, exc: InvoiceError): async def invoice_error_handler(request: Request, exc: InvoiceError):
logger.error(f"InvoiceError: {exc.message}, Status: {exc.status}") logger.error(f"{exc.message}, Status: {exc.status}")
return JSONResponse( return JSONResponse(
status_code=520, status_code=520,
content={"detail": exc.message, "status": exc.status}, content={"detail": exc.message, "status": exc.status},
+18 -30
View File
@@ -1,15 +1,15 @@
import json import json
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, List, Optional, Type from typing import Any, Optional, Type
import jinja2 import jinja2
import jwt
import shortuuid import shortuuid
from jose import jwt
from pydantic import BaseModel
from pydantic.schema import field_schema from pydantic.schema import field_schema
from lnbits.core.extensions.models import Extension
from lnbits.jinja2_templating import Jinja2Templates from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class from lnbits.nodes import get_node_class
from lnbits.requestvars import g from lnbits.requestvars import g
@@ -17,7 +17,6 @@ from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher from lnbits.utils.crypto import AESCipher
from .db import FilterModel from .db import FilterModel
from .extension_manager import get_valid_extensions
def get_db_vendor_name(): def get_db_vendor_name():
@@ -50,7 +49,7 @@ def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}" return f"/{static}/{path}?v={settings.server_startup_time}"
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates: def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templates:
folders = ["lnbits/templates", "lnbits/core/templates"] folders = ["lnbits/templates", "lnbits/core/templates"]
if additional_folders: if additional_folders:
additional_folders += [ additional_folders += [
@@ -92,19 +91,21 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
settings.lnbits_node_ui and get_node_class() is not None settings.lnbits_node_ui and get_node_class() is not None
) )
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
t.env.globals["EXTENSIONS"] = get_valid_extensions(False) t.env.globals["EXTENSIONS"] = Extension.get_valid_extensions(False)
if settings.lnbits_custom_logo: if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
if settings.bundle_assets: if settings.bundle_assets:
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"] t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"] t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
t.env.globals["INCLUDED_COMPONENTS"] = ["bundle-components.min.js"]
else: else:
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json") vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
with open(vendor_filepath) as vendor_file: with open(vendor_filepath) as vendor_file:
vendor_files = json.loads(vendor_file.read()) vendor_files = json.loads(vendor_file.read())
t.env.globals["INCLUDED_JS"] = vendor_files["js"] t.env.globals["INCLUDED_JS"] = vendor_files["js"]
t.env.globals["INCLUDED_CSS"] = vendor_files["css"] t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
@@ -172,28 +173,6 @@ def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False
} }
def insert_query(table_name: str, model: BaseModel) -> str:
"""
Generate an insert query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
"""
placeholders = ", ".join(["?"] * len(model.dict().keys()))
fields = ", ".join(model.dict().keys())
return f"INSERT INTO {table_name} ({fields}) VALUES ({placeholders})"
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str:
"""
Generate an update query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
:param where: Where string, default to `WHERE id = ?`
"""
query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
return f"UPDATE {table_name} SET {query} {where}"
def is_valid_email_address(email: str) -> bool: def is_valid_email_address(email: str) -> bool:
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}" email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
return re.fullmatch(email_regex, email) is not None return re.fullmatch(email_regex, email) is not None
@@ -205,7 +184,9 @@ def is_valid_username(username: str) -> bool:
def create_access_token(data: dict): def create_access_token(data: dict):
expire = datetime.utcnow() + timedelta(minutes=settings.auth_token_expire_minutes) expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.auth_token_expire_minutes
)
to_encode = data.copy() to_encode = data.copy()
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.auth_secret_key, "HS256") return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
@@ -223,3 +204,10 @@ def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
if not m: if not m:
return None return None
return AESCipher(key=settings.auth_secret_key).decrypt(m) return AESCipher(key=settings.auth_secret_key).decrypt(m)
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
if not filter_keys:
# return shallow clone of the dict even if there are no filters
return {**data}
return {key: data[key] for key in filter_keys if key in data}
+65 -1
View File
@@ -1 +1,65 @@
from lnurl import LnurlErrorResponse, decode, encode, handle # noqa: F401 from typing import Callable
from fastapi import HTTPException, Request, Response
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from lnurl import LnurlErrorResponse, decode, encode, handle
from loguru import logger
from lnbits.exceptions import InvoiceError, PaymentError
class LnurlErrorResponseHandler(APIRoute):
"""
Custom APIRoute class to handle LNURL errors.
LNURL errors always return with status 200 and
a JSON response with `status="ERROR"` and a `reason` key.
Helps to catch HTTPException and return a valid lnurl error response
Example:
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
"""
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def lnurl_route_handler(request: Request) -> Response:
try:
response = await original_route_handler(request)
return response
except (InvoiceError, PaymentError) as exc:
logger.debug(f"Wallet Error: {exc}")
response = JSONResponse(
status_code=200,
content={"status": "ERROR", "reason": f"{exc.message}"},
)
return response
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=200,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
return response
except Exception as exc:
logger.error("Unknown Error:", exc)
response = JSONResponse(
status_code=200,
content={
"status": "ERROR",
"reason": f"UNKNOWN ERROR: {exc!s}",
},
)
return response
return lnurl_route_handler
__all__ = [
"decode",
"encode",
"handle",
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
]
+7 -76
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Any, List, Tuple, Union from typing import Any, List, Union
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -45,16 +45,11 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
upgrade_path = next(
(
e
for e in settings.lnbits_upgraded_extensions
if e.endswith(f"/{top_path}")
),
None,
)
# re-route all trafic if the extension has been upgraded # re-route all trafic if the extension has been upgraded
if upgrade_path: if top_path in settings.lnbits_upgraded_extensions:
upgrade_path = (
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
)
tail = "/".join(rest) tail = "/".join(rest)
scope["path"] = f"/upgrades/{upgrade_path}/{tail}" scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
@@ -118,72 +113,12 @@ class ExtensionsRedirectMiddleware:
return return
req_headers = scope["headers"] if "headers" in scope else [] req_headers = scope["headers"] if "headers" in scope else []
redirect = self._find_redirect(scope["path"], req_headers) redirect = settings.find_extension_redirect(scope["path"], req_headers)
if redirect: if redirect:
scope["path"] = self._new_path(redirect, scope["path"]) scope["path"] = redirect.new_path_from(scope["path"])
await self.app(scope, receive, send) await self.app(scope, receive, send)
def _find_redirect(self, path: str, req_headers: List[Tuple[bytes, bytes]]):
return next(
(
r
for r in settings.lnbits_extensions_redirects
if self._redirect_matches(r, path, req_headers)
),
None,
)
def _redirect_matches(
self, redirect: dict, path: str, req_headers: List[Tuple[bytes, bytes]]
) -> bool:
if "from_path" not in redirect:
return False
header_filters = (
redirect["header_filters"] if "header_filters" in redirect else {}
)
return self._has_common_path(redirect["from_path"], path) and self._has_headers(
header_filters, req_headers
)
def _has_headers(
self, filter_headers: dict, req_headers: List[Tuple[bytes, bytes]]
) -> bool:
for h in filter_headers:
if not self._has_header(req_headers, (str(h), str(filter_headers[h]))):
return False
return True
def _has_header(
self, req_headers: List[Tuple[bytes, bytes]], header: Tuple[str, str]
) -> bool:
for h in req_headers:
if (
h[0].decode().lower() == header[0].lower()
and h[1].decode() == header[1]
):
return True
return False
def _has_common_path(self, redirect_path: str, req_path: str) -> bool:
redirect_path_elements = redirect_path.split("/")
req_path_elements = req_path.split("/")
if len(redirect_path) > len(req_path):
return False
sub_path = req_path_elements[: len(redirect_path_elements)]
return redirect_path == "/".join(sub_path)
def _new_path(self, redirect: dict, req_path: str) -> str:
from_path = redirect["from_path"].split("/")
redirect_to = redirect["redirect_to_path"].split("/")
req_tail_path = req_path.split("/")[len(from_path) :]
elements = [
e for e in ([redirect["ext_id"], *redirect_to, *req_tail_path]) if e != ""
]
return "/" + "/".join(elements)
def add_ratelimit_middleware(app: FastAPI): def add_ratelimit_middleware(app: FastAPI):
core_app_extra.register_new_ratelimiter() core_app_extra.register_new_ratelimiter()
@@ -214,8 +149,6 @@ def add_ip_block_middleware(app: FastAPI):
) )
return await call_next(request) return await call_next(request)
app.middleware("http")(block_allow_ip_middleware)
def add_first_install_middleware(app: FastAPI): def add_first_install_middleware(app: FastAPI):
@app.middleware("http") @app.middleware("http")
@@ -228,5 +161,3 @@ def add_first_install_middleware(app: FastAPI):
): ):
return RedirectResponse("/first_install") return RedirectResponse("/first_install")
return await call_next(request) return await call_next(request)
app.middleware("http")(first_install_middleware)
+1 -1
View File
@@ -16,7 +16,7 @@ from lnbits.settings import set_cli_settings, settings
} }
) )
@click.option("--port", default=settings.port, help="Port to listen on") @click.option("--port", default=settings.port, help="Port to listen on")
@click.option("--host", default=settings.host, help="Host to run LNBits on") @click.option("--host", default=settings.host, help="Host to run LNbits on")
@click.option( @click.option(
"--forwarded-allow-ips", "--forwarded-allow-ips",
default=settings.forwarded_allow_ips, default=settings.forwarded_allow_ips,
+173 -17
View File
@@ -7,7 +7,6 @@ import json
from enum import Enum from enum import Enum
from hashlib import sha256 from hashlib import sha256
from os import path from os import path
from sqlite3 import Row
from time import time from time import time
from typing import Any, Optional from typing import Any, Optional
@@ -47,6 +46,7 @@ class UsersSettings(LNbitsSettings):
class ExtensionsSettings(LNbitsSettings): class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: list[str] = Field(default=[]) lnbits_admin_extensions: list[str] = Field(default=[])
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False) lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_manifests: list[str] = Field( lnbits_extensions_manifests: list[str] = Field(
default=[ default=[
@@ -61,26 +61,132 @@ class ExtensionsInstallSettings(LNbitsSettings):
lnbits_ext_github_token: str = Field(default="") lnbits_ext_github_token: str = Field(default="")
class RedirectPath(BaseModel):
ext_id: str
from_path: str
redirect_to_path: str
header_filters: dict = {}
def in_conflict(self, other: RedirectPath) -> bool:
if self.ext_id == other.ext_id:
return False
return self.redirect_matches(
other.from_path, list(other.header_filters.items())
) or other.redirect_matches(self.from_path, list(self.header_filters.items()))
def find_in_conflict(self, others: list[RedirectPath]) -> Optional[RedirectPath]:
for other in others:
if self.in_conflict(other):
return other
return None
def new_path_from(self, req_path: str) -> str:
from_path = self.from_path.split("/")
redirect_to = self.redirect_to_path.split("/")
req_tail_path = req_path.split("/")[len(from_path) :]
elements = [e for e in ([self.ext_id, *redirect_to, *req_tail_path]) if e != ""]
return "/" + "/".join(elements)
def redirect_matches(self, path: str, req_headers: list[tuple[str, str]]) -> bool:
return self._has_common_path(path) and self._has_headers(req_headers)
def _has_common_path(self, req_path: str) -> bool:
if len(self.from_path) > len(req_path):
return False
redirect_path_elements = self.from_path.split("/")
req_path_elements = req_path.split("/")
sub_path = req_path_elements[: len(redirect_path_elements)]
return self.from_path == "/".join(sub_path)
def _has_headers(self, req_headers: list[tuple[str, str]]) -> bool:
for h in self.header_filters:
if not self._has_header(req_headers, (str(h), str(self.header_filters[h]))):
return False
return True
def _has_header(
self, req_headers: list[tuple[str, str]], header: tuple[str, str]
) -> bool:
for h in req_headers:
if h[0].lower() == header[0].lower() and h[1].lower() == header[1].lower():
return True
return False
class InstalledExtensionsSettings(LNbitsSettings): class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated # installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=[]) lnbits_deactivated_extensions: set[str] = Field(default=[])
# upgraded extensions that require API redirects # upgraded extensions that require API redirects
lnbits_upgraded_extensions: set[str] = Field(default=[]) lnbits_upgraded_extensions: dict[str, str] = Field(default={})
# list of redirects that extensions want to perform # list of redirects that extensions want to perform
lnbits_extensions_redirects: list[Any] = Field(default=[]) lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
# list of all extension ids # list of all extension ids
lnbits_all_extensions_ids: set[Any] = Field(default=[]) lnbits_all_extensions_ids: set[Any] = Field(default=[])
def extension_upgrade_path(self, ext_id: str) -> Optional[str]: def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]]
) -> Optional[RedirectPath]:
headers = [(k.decode(), v.decode()) for k, v in req_headers]
return next( return next(
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")), (
r
for r in self.lnbits_extensions_redirects
if r.redirect_matches(path, headers)
),
None, None,
) )
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]: def activate_extension_paths(
path = settings.extension_upgrade_path(ext_id) self,
return path.split("/")[0] if path else None ext_id: str,
upgrade_hash: Optional[str] = None,
ext_redirects: Optional[list[dict]] = None,
):
self.lnbits_deactivated_extensions.discard(ext_id)
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
self.lnbits_upgraded_extensions[ext_id] = upgrade_hash
if ext_redirects:
self._activate_extension_redirects(ext_id, ext_redirects)
self.lnbits_all_extensions_ids.add(ext_id)
def deactivate_extension_paths(self, ext_id: str):
self.lnbits_deactivated_extensions.add(ext_id)
self._remove_extension_redirects(ext_id)
def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]):
ext_redirect_paths = [
RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects
]
existing_redirects = {
r.ext_id
for r in self.lnbits_extensions_redirects
if r.find_in_conflict(ext_redirect_paths)
}
assert len(existing_redirects) == 0, (
f"Cannot redirect for extension '{ext_id}'."
f" Already mapped by {existing_redirects}."
)
self._remove_extension_redirects(ext_id)
self.lnbits_extensions_redirects += ext_redirect_paths
def _remove_extension_redirects(self, ext_id: str):
self.lnbits_extensions_redirects = [
er for er in self.lnbits_extensions_redirects if er.ext_id != ext_id
]
class ThemesSettings(LNbitsSettings): class ThemesSettings(LNbitsSettings):
@@ -217,6 +323,12 @@ class LnPayFundingSource(LNbitsSettings):
lnpay_admin_key: Optional[str] = Field(default=None) lnpay_admin_key: Optional[str] = Field(default=None)
class BlinkFundingSource(LNbitsSettings):
blink_api_endpoint: Optional[str] = Field(default="https://api.blink.sv/graphql")
blink_ws_endpoint: Optional[str] = Field(default="wss://ws.blink.sv/graphql")
blink_token: Optional[str] = Field(default=None)
class ZBDFundingSource(LNbitsSettings): class ZBDFundingSource(LNbitsSettings):
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/") zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
zbd_api_key: Optional[str] = Field(default=None) zbd_api_key: Optional[str] = Field(default=None)
@@ -251,6 +363,25 @@ class LnTipsFundingSource(LNbitsSettings):
lntips_invoice_key: Optional[str] = Field(default=None) lntips_invoice_key: Optional[str] = Field(default=None)
class NWCFundingSource(LNbitsSettings):
nwc_pairing_url: Optional[str] = Field(default=None)
class BreezSdkFundingSource(LNbitsSettings):
breez_api_key: Optional[str] = Field(default=None)
breez_greenlight_seed: Optional[str] = Field(default=None)
breez_greenlight_invite_code: Optional[str] = Field(default=None)
breez_greenlight_device_key: Optional[str] = Field(default=None)
breez_greenlight_device_cert: Optional[str] = Field(default=None)
class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: Optional[str] = Field(default="127.0.0.1:9002")
boltz_client_macaroon: Optional[str] = Field(default=None)
boltz_client_wallet: Optional[str] = Field(default="lnbits")
boltz_client_cert: Optional[str] = Field(default=None)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600) lightning_invoice_expiry: int = Field(default=3600)
@@ -265,12 +396,16 @@ class FundingSourcesSettings(
LndRestFundingSource, LndRestFundingSource,
LndGrpcFundingSource, LndGrpcFundingSource,
LnPayFundingSource, LnPayFundingSource,
BlinkFundingSource,
AlbyFundingSource, AlbyFundingSource,
BoltzFundingSource,
ZBDFundingSource, ZBDFundingSource,
PhoenixdFundingSource, PhoenixdFundingSource,
OpenNodeFundingSource, OpenNodeFundingSource,
SparkFundingSource, SparkFundingSource,
LnTipsFundingSource, LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
): ):
lnbits_backend_wallet_class: str = Field(default="VoidWallet") lnbits_backend_wallet_class: str = Field(default="VoidWallet")
@@ -293,10 +428,22 @@ class NodeUISettings(LNbitsSettings):
class AuthMethods(Enum): class AuthMethods(Enum):
user_id_only = "user-id-only" user_id_only = "user-id-only"
username_and_password = "username-password" username_and_password = "username-password"
nostr_auth_nip98 = "nostr-auth-nip98"
google_auth = "google-auth" google_auth = "google-auth"
github_auth = "github-auth" github_auth = "github-auth"
keycloak_auth = "keycloak-auth" keycloak_auth = "keycloak-auth"
@classmethod
def all(cls):
return [
AuthMethods.user_id_only.value,
AuthMethods.username_and_password.value,
AuthMethods.nostr_auth_nip98.value,
AuthMethods.google_auth.value,
AuthMethods.github_auth.value,
AuthMethods.keycloak_auth.value,
]
class AuthSettings(LNbitsSettings): class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600) auth_token_expire_minutes: int = Field(default=525600)
@@ -307,11 +454,20 @@ class AuthSettings(LNbitsSettings):
AuthMethods.username_and_password.value, AuthMethods.username_and_password.value,
] ]
) )
# 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)
def is_auth_method_allowed(self, method: AuthMethods): def is_auth_method_allowed(self, method: AuthMethods):
return method.value in self.auth_allowed_methods return method.value in self.auth_allowed_methods
class NostrAuthSettings(LNbitsSettings):
nostr_absolute_request_urls: list[str] = Field(
default=["http://127.0.0.1:5000", "http://localhost:5000"]
)
class GoogleAuthSettings(LNbitsSettings): class GoogleAuthSettings(LNbitsSettings):
google_client_id: str = Field(default="") google_client_id: str = Field(default="")
google_client_secret: str = Field(default="") google_client_secret: str = Field(default="")
@@ -339,6 +495,7 @@ class EditableSettings(
WebPushSettings, WebPushSettings,
NodeUISettings, NodeUISettings,
AuthSettings, AuthSettings,
NostrAuthSettings,
GoogleAuthSettings, GoogleAuthSettings,
GitHubAuthSettings, GitHubAuthSettings,
KeycloakAuthSettings, KeycloakAuthSettings,
@@ -414,19 +571,23 @@ class SuperUserSettings(LNbitsSettings):
lnbits_allowed_funding_sources: list[str] = Field( lnbits_allowed_funding_sources: list[str] = Field(
default=[ default=[
"AlbyWallet", "AlbyWallet",
"FakeWallet", "BoltzWallet",
"BlinkWallet",
"BreezSdkWallet",
"CoreLightningRestWallet", "CoreLightningRestWallet",
"CoreLightningWallet", "CoreLightningWallet",
"EclairWallet", "EclairWallet",
"LNbitsWallet", "FakeWallet",
"LndRestWallet",
"LNPayWallet", "LNPayWallet",
"LNbitsWallet",
"LnTipsWallet", "LnTipsWallet",
"LndRestWallet",
"LndWallet", "LndWallet",
"OpenNodeWallet", "OpenNodeWallet",
"PhoenixdWallet", "PhoenixdWallet",
"VoidWallet", "VoidWallet",
"ZBDWallet", "ZBDWallet",
"NWCWallet",
] ]
) )
@@ -457,7 +618,7 @@ class ReadOnlySettings(
PersistenceSettings, PersistenceSettings,
SuperUserSettings, SuperUserSettings,
): ):
lnbits_admin_ui: bool = Field(default=False) lnbits_admin_ui: bool = Field(default=True)
@validator( @validator(
"lnbits_allowed_funding_sources", "lnbits_allowed_funding_sources",
@@ -473,11 +634,6 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings): class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
@classmethod
def from_row(cls, row: Row) -> Settings:
data = dict(row)
return cls(**data)
class Config: class Config:
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8" env_file_encoding = "utf-8"
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
+34 -46
View File
File diff suppressed because one or more lines are too long
+20 -8
View File
@@ -530,23 +530,26 @@ video {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.qrcode__wrapper canvas { .qrcode__wrapper {
position: relative; position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode__wrapper canvas {
width: 100% !important; width: 100% !important;
max-width: 100%; height: 100% !important;
max-height: 100%; max-width: 350px;
} }
.qrcode__image { .qrcode__image {
position: absolute;
max-width: 52px;
width: 15%; width: 15%;
height: 15%;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
left: 50%;
overflow: hidden; overflow: hidden;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
padding: 0.2rem; padding: 0.2rem;
border-radius: 0.2rem; border-radius: 0.2rem;
} }
@@ -554,3 +557,12 @@ video {
.whitespace-pre-line { .whitespace-pre-line {
white-space: pre-line; white-space: pre-line;
} }
.q-carousel__slide {
background-size: contain;
background-repeat: no-repeat;
}
.q-dialog__inner--minimized {
padding: 12px;
}
+11 -12
View File
@@ -35,9 +35,9 @@ window.localisation.br = {
reset_defaults_tooltip: reset_defaults_tooltip:
'Apagar todas as configurações e redefinir para os padrões.', 'Apagar todas as configurações e redefinir para os padrões.',
download_backup: 'Fazer backup do banco de dados', download_backup: 'Fazer backup do banco de dados',
name_your_wallet: 'Nomeie sua carteira %{name}', name_your_wallet: 'Nomeie sua carteira {name}',
wallet_topup_ok: wallet_topup_ok:
'Sucesso ao criar fundos virtuais (%{amount} sats). Pagamentos dependem dos fundos reais na fonte de financiamento.', 'Sucesso ao criar fundos virtuais ({amount} sats). Pagamentos dependem dos fundos reais na fonte de financiamento.',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *', paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description: lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.', 'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
@@ -58,10 +58,9 @@ window.localisation.br = {
donate: 'Doar', donate: 'Doar',
view_github: 'Ver no GitHub', view_github: 'Ver no GitHub',
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados', voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
use_with_caution: 'USE COM CAUTELA - a carteira %{name} ainda está em BETA', use_with_caution: 'USE COM CAUTELA - a carteira {name} ainda está em BETA',
service_fee: 'Taxa de serviço: %{amount} % por transação', service_fee: 'Taxa de serviço: {amount} % por transação',
service_fee_max: service_fee_max: 'Taxa de serviço: {amount} % por transação (máx {max} sats)',
'Taxa de serviço: %{amount} % por transação (máx %{max} sats)',
service_fee_tooltip: service_fee_tooltip:
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída', 'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
toggle_darkmode: 'Alternar modo escuro', toggle_darkmode: 'Alternar modo escuro',
@@ -72,7 +71,7 @@ window.localisation.br = {
lnbits_version: 'Versão do LNbits', lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em', runs_on: 'Executa em',
credit_hint: 'Pressione Enter para creditar a conta', credit_hint: 'Pressione Enter para creditar a conta',
credit_label: '%{denomination} para creditar', credit_label: '{denomination} para creditar',
paste: 'Colar', paste: 'Colar',
paste_from_clipboard: 'Cole do clipboard', paste_from_clipboard: 'Cole do clipboard',
paste_request: 'Colar Pedido', paste_request: 'Colar Pedido',
@@ -155,8 +154,8 @@ window.localisation.br = {
expiry: 'Validade', expiry: 'Validade',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Comprovante de pagamento', payment_proof: 'Comprovante de pagamento',
update_available: 'Atualização %{version} disponível!', update_available: 'Atualização {version} disponível!',
latest_update: 'Você está na versão mais recente %{version}.', latest_update: 'Você está na versão mais recente {version}.',
notifications: 'Notificações', notifications: 'Notificações',
no_notifications: 'Sem notificações', no_notifications: 'Sem notificações',
notifications_disabled: notifications_disabled:
@@ -169,7 +168,7 @@ window.localisation.br = {
'Se ativado, mudará sua fonte de fundos para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.', 'Se ativado, mudará sua fonte de fundos para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.',
killswitch_interval: 'Intervalo do Killswitch', killswitch_interval: 'Intervalo do Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNBits proveniente da fonte de status (em minutos).', 'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNbits proveniente da fonte de status (em minutos).',
enable_watchdog: 'Ativar Watchdog', enable_watchdog: 'Ativar Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Se ativado, ele mudará automaticamente sua fonte de financiamento para VoidWallet se o seu saldo for inferior ao saldo do LNbits. Você precisará ativar manualmente após uma atualização.', 'Se ativado, ele mudará automaticamente sua fonte de financiamento para VoidWallet se o seu saldo for inferior ao saldo do LNbits. Você precisará ativar manualmente após uma atualização.',
@@ -247,8 +246,8 @@ window.localisation.br = {
look_and_feel: 'Aparência', look_and_feel: 'Aparência',
language: 'Idioma', language: 'Idioma',
color_scheme: 'Esquema de Cores', color_scheme: 'Esquema de Cores',
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.', extension_cost: 'Este lançamento requer um pagamento mínimo de {cost} sats.',
extension_paid_sats: 'Você já pagou %{paid_sats} sats.', extension_paid_sats: 'Você já pagou {paid_sats} sats.',
release_details_error: 'Não é possível obter os detalhes da versão.', release_details_error: 'Não é possível obter os detalhes da versão.',
pay_from_wallet: 'Pagar com a Carteira', pay_from_wallet: 'Pagar com a Carteira',
show_qr: 'Exibir QR', show_qr: 'Exibir QR',
+11 -11
View File
@@ -34,9 +34,9 @@ window.localisation.cn = {
reset_defaults: '重置为默认设置', reset_defaults: '重置为默认设置',
reset_defaults_tooltip: '删除所有设置并重置为默认设置', reset_defaults_tooltip: '删除所有设置并重置为默认设置',
download_backup: '下载数据库备份', download_backup: '下载数据库备份',
name_your_wallet: '给你的 %{name}钱包起个名字', name_your_wallet: '给你的 {name}钱包起个名字',
wallet_topup_ok: wallet_topup_ok:
'成功创建虚拟资金(%{amount} sats)。付款取决于资金来源的实际资金。', '成功创建虚拟资金({amount} sats)。付款取决于资金来源的实际资金。',
paste_invoice_label: '粘贴发票,付款请求或lnurl*', paste_invoice_label: '粘贴发票,付款请求或lnurl*',
lnbits_description: lnbits_description:
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。', 'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
@@ -56,9 +56,9 @@ window.localisation.cn = {
donate: '捐献', donate: '捐献',
view_github: '在GitHub上查看', view_github: '在GitHub上查看',
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。', voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
use_with_caution: '请谨慎使用 - %{name}钱包还处于测试版阶段', use_with_caution: '请谨慎使用 - {name}钱包还处于测试版阶段',
service_fee: '服务费:%{amount}% 每笔交易', service_fee: '服务费:{amount}% 每笔交易',
service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats', service_fee_max: '服务费:{amount}% 每笔交易(最高 {max} sats',
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费', service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
toggle_darkmode: '切换暗黑模式', toggle_darkmode: '切换暗黑模式',
payment_reactions: '支付反应', payment_reactions: '支付反应',
@@ -68,7 +68,7 @@ window.localisation.cn = {
lnbits_version: 'LNbits版本', lnbits_version: 'LNbits版本',
runs_on: '可运行在', runs_on: '可运行在',
credit_hint: '按 Enter 键充值账户', credit_hint: '按 Enter 键充值账户',
credit_label: '%{denomination} 充值', credit_label: '{denomination} 充值',
paste: '粘贴', paste: '粘贴',
paste_from_clipboard: '从剪贴板粘贴', paste_from_clipboard: '从剪贴板粘贴',
paste_request: '粘贴请求', paste_request: '粘贴请求',
@@ -146,8 +146,8 @@ window.localisation.cn = {
expiry: '过期时间', expiry: '过期时间',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: '付款证明', payment_proof: '付款证明',
update_available: '更新%{version}可用!', update_available: '更新{version}可用!',
latest_update: '您当前使用的是最新版本%{version}。', latest_update: '您当前使用的是最新版本{version}。',
notifications: '通知', notifications: '通知',
no_notifications: '没有通知', no_notifications: '没有通知',
notifications_disabled: 'LNbits状态通知已禁用。', notifications_disabled: 'LNbits状态通知已禁用。',
@@ -159,7 +159,7 @@ window.localisation.cn = {
'如果启用,当LNbits发送终止信号时,系统将自动将您的资金来源更改为VoidWallet。更新后,您将需要手动启用。', '如果启用,当LNbits发送终止信号时,系统将自动将您的资金来源更改为VoidWallet。更新后,您将需要手动启用。',
killswitch_interval: 'Killswitch 间隔', killswitch_interval: 'Killswitch 间隔',
killswitch_interval_desc: killswitch_interval_desc:
'后台任务应该多久检查一次来自状态源的LNBits断路信号(以分钟为单位)。', '后台任务应该多久检查一次来自状态源的LNbits断路信号(以分钟为单位)。',
enable_watchdog: '启用看门狗', enable_watchdog: '启用看门狗',
enable_watchdog_desc: enable_watchdog_desc:
'如果启用,当您的余额低于LNbits余额时,系统将自动将您的资金来源更改为VoidWallet。更新后您将需要手动启用。', '如果启用,当您的余额低于LNbits余额时,系统将自动将您的资金来源更改为VoidWallet。更新后您将需要手动启用。',
@@ -235,8 +235,8 @@ window.localisation.cn = {
look_and_feel: '外观和感觉', look_and_feel: '外观和感觉',
language: '语言', language: '语言',
color_scheme: '配色方案', color_scheme: '配色方案',
extension_cost: '此版本需要支付最低 %{cost} sats。', extension_cost: '此版本需要支付最低 {cost} sats。',
extension_paid_sats: '您已经支付了%{paid_sats} sats。', extension_paid_sats: '您已经支付了{paid_sats} sats。',
release_details_error: '无法获取发布详情。', release_details_error: '无法获取发布详情。',
pay_from_wallet: '从钱包支付', pay_from_wallet: '从钱包支付',
show_qr: '显示QR码', show_qr: '显示QR码',
+11 -11
View File
@@ -34,9 +34,9 @@ window.localisation.cs = {
reset_defaults: 'Obnovit výchozí', reset_defaults: 'Obnovit výchozí',
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.', reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
download_backup: 'Stáhnout zálohu databáze', download_backup: 'Stáhnout zálohu databáze',
name_your_wallet: 'Pojmenujte svou %{name} peněženku', name_your_wallet: 'Pojmenujte svou {name} peněženku',
wallet_topup_ok: wallet_topup_ok:
'Úspěšně vytvořeny virtuální prostředky (%{amount} sats). Platby závisí na skutečných prostředcích na zdrojovém účtu.', 'Úspěšně vytvořeny virtuální prostředky ({amount} sats). Platby závisí na skutečných prostředcích na zdrojovém účtu.',
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *', paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
lnbits_description: lnbits_description:
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.', 'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
@@ -58,10 +58,10 @@ window.localisation.cs = {
view_github: 'Zobrazit na GitHubu', view_github: 'Zobrazit na GitHubu',
voidwallet_active: 'VoidWallet je aktivní! Platby zakázány', voidwallet_active: 'VoidWallet je aktivní! Platby zakázány',
use_with_caution: use_with_caution:
'POUŽÍVEJTE S OBEZŘETNOSTÍ - %{name} peněženka je stále v BETĚ', 'POUŽÍVEJTE S OBEZŘETNOSTÍ - {name} peněženka je stále v BETĚ',
service_fee: 'Servisný poplatek: %{amount} % za transakci', service_fee: 'Servisný poplatek: {amount} % za transakci',
service_fee_max: service_fee_max:
'Servisný poplatek: %{amount} % za transakci (max %{max} satoshi)', 'Servisný poplatek: {amount} % za transakci (max {max} satoshi)',
service_fee_tooltip: service_fee_tooltip:
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci', 'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
toggle_darkmode: 'Přepnout tmavý režim', toggle_darkmode: 'Přepnout tmavý režim',
@@ -72,7 +72,7 @@ window.localisation.cs = {
lnbits_version: 'Verze LNbits', lnbits_version: 'Verze LNbits',
runs_on: 'Běží na', runs_on: 'Běží na',
credit_hint: 'Stiskněte Enter pro připsání na účet', credit_hint: 'Stiskněte Enter pro připsání na účet',
credit_label: '%{denomination} k připsání', credit_label: '{denomination} k připsání',
paste: 'Vložit', paste: 'Vložit',
paste_from_clipboard: 'Vložit ze schránky', paste_from_clipboard: 'Vložit ze schránky',
paste_request: 'Vložit požadavek', paste_request: 'Vložit požadavek',
@@ -153,8 +153,8 @@ window.localisation.cs = {
expiry: 'Expirace', expiry: 'Expirace',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Důkaz platby', payment_proof: 'Důkaz platby',
update_available: 'Dostupná aktualizace %{version}!', update_available: 'Dostupná aktualizace {version}!',
latest_update: 'Máte nejnovější verzi %{version}.', latest_update: 'Máte nejnovější verzi {version}.',
notifications: 'Notifikace', notifications: 'Notifikace',
no_notifications: 'Žádné notifikace', no_notifications: 'Žádné notifikace',
notifications_disabled: 'Notifikace stavu LNbits jsou zakázány.', notifications_disabled: 'Notifikace stavu LNbits jsou zakázány.',
@@ -166,7 +166,7 @@ window.localisation.cs = {
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud LNbits odešle signál killswitch. Po aktualizaci budete muset povolit ručně.', 'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud LNbits odešle signál killswitch. Po aktualizaci budete muset povolit ručně.',
killswitch_interval: 'Interval Killswitch', killswitch_interval: 'Interval Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
'Jak často by měl úkol na pozadí kontrolovat signál killswitch od LNBits ze zdroje stavu (v minutách).', 'Jak často by měl úkol na pozadí kontrolovat signál killswitch od LNbits ze zdroje stavu (v minutách).',
enable_watchdog: 'Povolit Watchdog', enable_watchdog: 'Povolit Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud je váš zůstatek nižší než zůstatek LNbits. Po aktualizaci budete muset povolit ručně.', 'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud je váš zůstatek nižší než zůstatek LNbits. Po aktualizaci budete muset povolit ručně.',
@@ -244,8 +244,8 @@ window.localisation.cs = {
look_and_feel: 'Vzhled a chování', look_and_feel: 'Vzhled a chování',
language: 'Jazyk', language: 'Jazyk',
color_scheme: 'Barevné schéma', color_scheme: 'Barevné schéma',
extension_cost: 'Toto vydání vyžaduje minimální platbu %{cost} satoshi.', extension_cost: 'Toto vydání vyžaduje minimální platbu {cost} satoshi.',
extension_paid_sats: 'Již jste zaplatili %{paid_sats} sats.', extension_paid_sats: 'Již jste zaplatili {paid_sats} sats.',
release_details_error: 'Nelze získat podrobnosti o vydání.', release_details_error: 'Nelze získat podrobnosti o vydání.',
pay_from_wallet: 'Platit z peněženky', pay_from_wallet: 'Platit z peněženky',
show_qr: 'Zobrazit QR', show_qr: 'Zobrazit QR',
+11 -13
View File
@@ -35,9 +35,9 @@ window.localisation.de = {
reset_defaults_tooltip: reset_defaults_tooltip:
'Alle Einstellungen auf die Standardeinstellungen zurücksetzen.', 'Alle Einstellungen auf die Standardeinstellungen zurücksetzen.',
download_backup: 'Datenbank-Backup herunterladen', download_backup: 'Datenbank-Backup herunterladen',
name_your_wallet: 'Vergib deiner %{name} Wallet einen Namen', name_your_wallet: 'Vergib deiner {name} Wallet einen Namen',
wallet_topup_ok: wallet_topup_ok:
'Erfolg beim Erstellen von virtuellen Mitteln (%{amount} Satoshis). Zahlungen hängen von den tatsächlichen Mitteln der Finanzierungsquelle ab.', 'Erfolg beim Erstellen von virtuellen Mitteln ({amount} Satoshis). Zahlungen hängen von den tatsächlichen Mitteln der Finanzierungsquelle ab.',
paste_invoice_label: paste_invoice_label:
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *', 'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
lnbits_description: lnbits_description:
@@ -59,11 +59,9 @@ window.localisation.de = {
donate: 'Spenden', donate: 'Spenden',
view_github: 'Auf GitHub anzeigen', view_github: 'Auf GitHub anzeigen',
voidwallet_active: 'VoidWallet ist aktiv! Zahlungen deaktiviert', voidwallet_active: 'VoidWallet ist aktiv! Zahlungen deaktiviert',
use_with_caution: use_with_caution: 'BITTE MIT VORSICHT BENUTZEN - {name} Wallet ist noch BETA',
'BITTE MIT VORSICHT BENUTZEN - %{name} Wallet ist noch BETA', service_fee: 'Dienstleistungsgebühr: {amount} % pro Transaktion',
service_fee: 'Dienstleistungsgebühr: %{amount} % pro Transaktion', service_fee_max: 'Servicegebühr: {amount} % pro Transaktion (max {max} Sats)',
service_fee_max:
'Servicegebühr: %{amount} % pro Transaktion (max %{max} Sats)',
service_fee_tooltip: service_fee_tooltip:
'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird', 'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird',
toggle_darkmode: 'Auf Dark Mode umschalten', toggle_darkmode: 'Auf Dark Mode umschalten',
@@ -74,7 +72,7 @@ window.localisation.de = {
lnbits_version: 'LNbits-Version', lnbits_version: 'LNbits-Version',
runs_on: 'Läuft auf', runs_on: 'Läuft auf',
credit_hint: 'Klicke Enter, um das Konto zu belasten', credit_hint: 'Klicke Enter, um das Konto zu belasten',
credit_label: '%{denomination} zu belasten', credit_label: '{denomination} zu belasten',
paste: 'Einfügen', paste: 'Einfügen',
paste_from_clipboard: 'Einfügen aus der Zwischenablage', paste_from_clipboard: 'Einfügen aus der Zwischenablage',
paste_request: 'Anfrage einfügen', paste_request: 'Anfrage einfügen',
@@ -158,8 +156,8 @@ window.localisation.de = {
expiry: 'Ablauf', expiry: 'Ablauf',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Beleg', payment_proof: 'Beleg',
update_available: 'Aktualisierung %{version} verfügbar!', update_available: 'Aktualisierung {version} verfügbar!',
latest_update: 'Sie sind auf der neuesten Version %{version}.', latest_update: 'Sie sind auf der neuesten Version {version}.',
notifications: 'Benachrichtigungen', notifications: 'Benachrichtigungen',
no_notifications: 'Keine Benachrichtigungen', no_notifications: 'Keine Benachrichtigungen',
notifications_disabled: 'LNbits Statusbenachrichtigungen sind deaktiviert.', notifications_disabled: 'LNbits Statusbenachrichtigungen sind deaktiviert.',
@@ -171,7 +169,7 @@ window.localisation.de = {
'Falls aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn LNbits ein Killswitch-Signal sendet. Nach einem Update müssen Sie dies manuell wieder aktivieren.', 'Falls aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn LNbits ein Killswitch-Signal sendet. Nach einem Update müssen Sie dies manuell wieder aktivieren.',
killswitch_interval: 'Intervall für den Notausschalter', killswitch_interval: 'Intervall für den Notausschalter',
killswitch_interval_desc: killswitch_interval_desc:
'Wie oft die Hintergrundaufgabe nach dem LNBits-Killswitch-Signal aus der Statusquelle suchen soll (in Minuten).', 'Wie oft die Hintergrundaufgabe nach dem LNbits-Killswitch-Signal aus der Statusquelle suchen soll (in Minuten).',
enable_watchdog: 'Aktiviere Watchdog', enable_watchdog: 'Aktiviere Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Wenn aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn Ihr Guthaben niedriger als das LNbits-Guthaben ist. Nach einem Update müssen Sie dies manuell aktivieren.', 'Wenn aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn Ihr Guthaben niedriger als das LNbits-Guthaben ist. Nach einem Update müssen Sie dies manuell aktivieren.',
@@ -252,8 +250,8 @@ window.localisation.de = {
language: 'Sprache', language: 'Sprache',
color_scheme: 'Farbschema', color_scheme: 'Farbschema',
extension_cost: extension_cost:
'Diese Version erfordert eine Zahlung von mindestens %{cost} Sats.', 'Diese Version erfordert eine Zahlung von mindestens {cost} Sats.',
extension_paid_sats: 'Sie haben bereits %{paid_sats} Sats bezahlt.', extension_paid_sats: 'Sie haben bereits {paid_sats} Sats bezahlt.',
release_details_error: 'Kann die Details zur Veröffentlichung nicht abrufen.', release_details_error: 'Kann die Details zur Veröffentlichung nicht abrufen.',
pay_from_wallet: 'Zahlen aus dem Geldbeutel', pay_from_wallet: 'Zahlen aus dem Geldbeutel',
show_qr: 'QR anzeigen', show_qr: 'QR anzeigen',
+24 -14
View File
@@ -34,9 +34,9 @@ window.localisation.en = {
reset_defaults: 'Reset to defaults', reset_defaults: 'Reset to defaults',
reset_defaults_tooltip: 'Delete all settings and reset to defaults.', reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
download_backup: 'Download database backup', download_backup: 'Download database backup',
name_your_wallet: 'Name your %{name} wallet', name_your_wallet: 'Name your {name} wallet',
wallet_topup_ok: wallet_topup_ok:
'Success creating virtual funds (%{amount} sats). Payments depend on actual funds on funding source.', 'Success creating virtual funds ({amount} sats). Payments depend on actual funds on funding source.',
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *', paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
lnbits_description: lnbits_description:
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.', 'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
@@ -57,9 +57,9 @@ window.localisation.en = {
donate: 'Donate', donate: 'Donate',
view_github: 'View on GitHub', view_github: 'View on GitHub',
voidwallet_active: 'VoidWallet is active! Payments disabled', voidwallet_active: 'VoidWallet is active! Payments disabled',
use_with_caution: 'USE WITH CAUTION - %{name} wallet is still in BETA', use_with_caution: 'USE WITH CAUTION - {name} wallet is still in BETA',
service_fee: 'Service fee: %{amount} % per transaction', service_fee: 'Service fee: {amount} % per transaction',
service_fee_max: 'Service fee: %{amount} % per transaction (max %{max} sats)', service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
service_fee_tooltip: service_fee_tooltip:
'Service fee charged by the LNbits server admin per outgoing transaction', 'Service fee charged by the LNbits server admin per outgoing transaction',
toggle_darkmode: 'Toggle Dark Mode', toggle_darkmode: 'Toggle Dark Mode',
@@ -70,7 +70,7 @@ window.localisation.en = {
lnbits_version: 'LNbits version', lnbits_version: 'LNbits version',
runs_on: 'Runs on', runs_on: 'Runs on',
credit_hint: 'Press Enter to credit account', credit_hint: 'Press Enter to credit account',
credit_label: '%{denomination} to credit', credit_label: '{denomination} to credit',
paste: 'Paste', paste: 'Paste',
paste_from_clipboard: 'Paste from clipboard', paste_from_clipboard: 'Paste from clipboard',
paste_request: 'Paste Request', paste_request: 'Paste Request',
@@ -152,8 +152,8 @@ window.localisation.en = {
expiry: 'Expiry', expiry: 'Expiry',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Payment Proof', payment_proof: 'Payment Proof',
update_available: 'Update %{version} available!', update_available: 'Update {version} available!',
latest_update: 'You are on the latest version %{version}.', latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications', notifications: 'Notifications',
no_notifications: 'No notifications', no_notifications: 'No notifications',
notifications_disabled: 'LNbits status notifications are disabled.', notifications_disabled: 'LNbits status notifications are disabled.',
@@ -165,7 +165,7 @@ window.localisation.en = {
'If enabled it will change your funding source to VoidWallet automatically if LNbits sends out a killswitch signal. You will need to enable manually after an update.', 'If enabled it will change your funding source to VoidWallet automatically if LNbits sends out a killswitch signal. You will need to enable manually after an update.',
killswitch_interval: 'Killswitch Interval', killswitch_interval: 'Killswitch Interval',
killswitch_interval_desc: killswitch_interval_desc:
'How often the background task should check for the LNBits killswitch signal from the status source (in minutes).', 'How often the background task should check for the LNbits killswitch signal from the status source (in minutes).',
enable_watchdog: 'Enable Watchdog', enable_watchdog: 'Enable Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.', 'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.',
@@ -194,7 +194,7 @@ window.localisation.en = {
rate_limiter: 'Rate Limiter', rate_limiter: 'Rate Limiter',
wallet_limiter: 'Wallet Limiter', wallet_limiter: 'Wallet Limiter',
wallet_limit_max_withdraw_per_day: wallet_limit_max_withdraw_per_day:
'Max daily wallet withdrawal in sats (0 to disable)', 'Max daily wallet withdrawal in sats (0 for no limit, -1 to block withdrawal)',
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)', wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',
wallet_limit_secs_between_trans: wallet_limit_secs_between_trans:
'Min secs between transactions per wallet (0 to disable)', 'Min secs between transactions per wallet (0 to disable)',
@@ -214,6 +214,7 @@ window.localisation.en = {
login_to_account: 'Login to your account', login_to_account: 'Login to your account',
create_account: 'Create account', create_account: 'Create account',
account_settings: 'Account Settings', account_settings: 'Account Settings',
signin_with_nostr: 'Continue with Nostr',
signin_with_google: 'Sign in with Google', signin_with_google: 'Sign in with Google',
signin_with_github: 'Sign in with GitHub', signin_with_github: 'Sign in with GitHub',
signin_with_keycloak: 'Sign in with Keycloak', signin_with_keycloak: 'Sign in with Keycloak',
@@ -222,11 +223,14 @@ window.localisation.en = {
password_config: 'Password Config', password_config: 'Password Config',
password_repeat: 'Password repeat', password_repeat: 'Password repeat',
change_password: 'Change Password', change_password: 'Change Password',
update_credentials: 'Update Credentials',
update_pubkey: 'Update Public Key',
set_password: 'Set Password', set_password: 'Set Password',
invalid_password: 'Password must have at least 8 characters', invalid_password: 'Password must have at least 8 characters',
login: 'Login', login: 'Login',
register: 'Register', register: 'Register',
username: 'Username', username: 'Username',
pubkey: 'Public Key',
user_id: 'User ID', user_id: 'User ID',
email: 'Email', email: 'Email',
first_name: 'First Name', first_name: 'First Name',
@@ -241,10 +245,12 @@ window.localisation.en = {
back: 'Back', back: 'Back',
logout: 'Logout', logout: 'Logout',
look_and_feel: 'Look and Feel', look_and_feel: 'Look and Feel',
toggle_gradient: 'Toggle Gradient',
gradient_background: 'Gradient Background',
language: 'Language', language: 'Language',
color_scheme: 'Color Scheme', color_scheme: 'Color Scheme',
extension_cost: 'This release requires a payment of minimum %{cost} sats.', extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid %{paid_sats} sats.', extension_paid_sats: 'You have already paid {paid_sats} sats.',
release_details_error: 'Cannot get the release details.', release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet', pay_from_wallet: 'Pay from Wallet',
wallet_required: 'Wallet *', wallet_required: 'Wallet *',
@@ -256,7 +262,11 @@ window.localisation.en = {
sell: 'Sell', sell: 'Sell',
sell_require: 'Ask payment to enable extension', sell_require: 'Ask payment to enable extension',
sell_info: sell_info:
'The %{name} extension requires a payment of minimum %{amount} sats to enable.', 'The {name} extension requires a payment of minimum {amount} sats to enable.',
hide_empty_wallets: 'Hide empty wallets', hide_empty_wallets: 'Hide empty wallets',
recheck: 'Recheck' recheck: 'Recheck',
contributors: 'Contributors',
license: 'License',
reset_key: 'Reset Key',
reset_password: 'Reset Password'
} }
+11 -11
View File
@@ -35,9 +35,9 @@ window.localisation.es = {
reset_defaults_tooltip: reset_defaults_tooltip:
'Borrar todas las configuraciones y restablecer a los valores predeterminados.', 'Borrar todas las configuraciones y restablecer a los valores predeterminados.',
download_backup: 'Descargar copia de seguridad de la base de datos', download_backup: 'Descargar copia de seguridad de la base de datos',
name_your_wallet: 'Nombre de su billetera %{name}', name_your_wallet: 'Nombre de su billetera {name}',
wallet_topup_ok: wallet_topup_ok:
'Éxito creando fondos virtuales (%{amount} sats). Los pagos dependen de los fondos reales en la fuente de financiación.', 'Éxito creando fondos virtuales ({amount} sats). Los pagos dependen de los fondos reales en la fuente de financiación.',
paste_invoice_label: 'Pegue la factura aquí', paste_invoice_label: 'Pegue la factura aquí',
lnbits_description: lnbits_description:
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.', 'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
@@ -58,10 +58,10 @@ window.localisation.es = {
donate: 'Donar', donate: 'Donar',
view_github: 'Ver en GitHub', view_github: 'Ver en GitHub',
voidwallet_active: '¡VoidWallet está activo! Pagos desactivados', voidwallet_active: '¡VoidWallet está activo! Pagos desactivados',
use_with_caution: 'USAR CON CUIDADO - %{name} Wallet aún está en BETA', use_with_caution: 'USAR CON CUIDADO - {name} Wallet aún está en BETA',
service_fee: 'Tarifa de servicio: %{amount} % por transacción', service_fee: 'Tarifa de servicio: {amount} % por transacción',
service_fee_max: service_fee_max:
'Tarifa de servicio: %{amount} % por transacción (máx %{max} sats)', 'Tarifa de servicio: {amount} % por transacción (máx {max} sats)',
service_fee_tooltip: service_fee_tooltip:
'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente', 'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente',
toggle_darkmode: 'Cambiar modo oscuro', toggle_darkmode: 'Cambiar modo oscuro',
@@ -72,7 +72,7 @@ window.localisation.es = {
lnbits_version: 'Versión de LNbits', lnbits_version: 'Versión de LNbits',
runs_on: 'Corre en', runs_on: 'Corre en',
credit_hint: 'Presione Enter para cargar la cuenta', credit_hint: 'Presione Enter para cargar la cuenta',
credit_label: 'Cargar %{denomination}', credit_label: 'Cargar {denomination}',
paste: 'Pegar', paste: 'Pegar',
paste_from_clipboard: 'Pegar desde el portapapeles', paste_from_clipboard: 'Pegar desde el portapapeles',
paste_request: 'Pegar solicitud', paste_request: 'Pegar solicitud',
@@ -155,8 +155,8 @@ window.localisation.es = {
expiry: 'Expiración', expiry: 'Expiración',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Prueba de pago', payment_proof: 'Prueba de pago',
update_available: '¡Actualización %{version} disponible!', update_available: '¡Actualización {version} disponible!',
latest_update: 'Usted está en la última versión %{version}.', latest_update: 'Usted está en la última versión {version}.',
notifications: 'Notificaciones', notifications: 'Notificaciones',
no_notifications: 'No hay notificaciones', no_notifications: 'No hay notificaciones',
notifications_disabled: notifications_disabled:
@@ -169,7 +169,7 @@ window.localisation.es = {
'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si LNbits envía una señal de parada de emergencia. Necesitará activarlo manualmente después de una actualización.', 'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si LNbits envía una señal de parada de emergencia. Necesitará activarlo manualmente después de una actualización.',
killswitch_interval: 'Intervalo de Killswitch', killswitch_interval: 'Intervalo de Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
'Con qué frecuencia la tarea en segundo plano debe verificar la señal de interruptor de emergencia de LNBits desde la fuente de estado (en minutos).', 'Con qué frecuencia la tarea en segundo plano debe verificar la señal de interruptor de emergencia de LNbits desde la fuente de estado (en minutos).',
enable_watchdog: 'Activar Watchdog', enable_watchdog: 'Activar Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si su saldo es inferior al saldo de LNbits. Tendrá que activarlo manualmente después de una actualización.', 'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si su saldo es inferior al saldo de LNbits. Tendrá que activarlo manualmente después de una actualización.',
@@ -249,8 +249,8 @@ window.localisation.es = {
look_and_feel: 'Apariencia', look_and_feel: 'Apariencia',
language: 'Idioma', language: 'Idioma',
color_scheme: 'Esquema de colores', color_scheme: 'Esquema de colores',
extension_cost: 'Esta versión requiere un pago mínimo de %{cost} sats.', extension_cost: 'Esta versión requiere un pago mínimo de {cost} sats.',
extension_paid_sats: 'Ya has pagado %{paid_sats} sats.', extension_paid_sats: 'Ya has pagado {paid_sats} sats.',
release_details_error: 'No se pueden obtener los detalles de la versión.', release_details_error: 'No se pueden obtener los detalles de la versión.',
pay_from_wallet: 'Pagar desde la billetera', pay_from_wallet: 'Pagar desde la billetera',
show_qr: 'Mostrar QR', show_qr: 'Mostrar QR',
+11 -12
View File
@@ -35,9 +35,9 @@ window.localisation.fi = {
reset_defaults_tooltip: reset_defaults_tooltip:
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.', 'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
download_backup: 'Lataa tietokannan varmuuskopio', download_backup: 'Lataa tietokannan varmuuskopio',
name_your_wallet: 'Anna %{name}-lompakollesi nimi', name_your_wallet: 'Anna {name}-lompakollesi nimi',
wallet_topup_ok: wallet_topup_ok:
'Virtuaalisten varojen luominen onnistui (%{amount} sats). Maksut riippuvat rahoituslähteen todellisista varoista.', 'Virtuaalisten varojen luominen onnistui ({amount} sats). Maksut riippuvat rahoituslähteen todellisista varoista.',
paste_invoice_label: paste_invoice_label:
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *', 'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
lnbits_description: lnbits_description:
@@ -61,12 +61,11 @@ window.localisation.fi = {
voidwallet_active: voidwallet_active:
'Maksutapahtumat ovat poissa käytöstä, koska VoidWallet on aktiivinen!', 'Maksutapahtumat ovat poissa käytöstä, koska VoidWallet on aktiivinen!',
use_with_caution: use_with_caution:
'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: %{name}', 'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: {name}',
service_fee: 'Palvelumaksu: %{amount} % tapahtumasta', service_fee: 'Palvelumaksu: {amount} % tapahtumasta',
service_fee_max: service_fee_max: 'Palvelumaksu: {amount} % tapahtumasta (enintään {max} sat)',
'Palvelumaksu: %{amount} % tapahtumasta (enintään %{max} sat)',
service_fee_tooltip: service_fee_tooltip:
'LNBits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.', 'LNbits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
toggle_darkmode: 'Tumma näkymä', toggle_darkmode: 'Tumma näkymä',
payment_reactions: 'Maksureaktiot', payment_reactions: 'Maksureaktiot',
view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit', view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit',
@@ -75,7 +74,7 @@ window.localisation.fi = {
lnbits_version: 'LNbits versio', lnbits_version: 'LNbits versio',
runs_on: 'Mukana menossa', runs_on: 'Mukana menossa',
credit_hint: 'Hyväksy painamalla Enter', credit_hint: 'Hyväksy painamalla Enter',
credit_label: 'Lisää tilille varoja %{denomination}', credit_label: 'Lisää tilille varoja {denomination}',
paste: 'Liitä', paste: 'Liitä',
paste_from_clipboard: 'Liitä leikepöydältä', paste_from_clipboard: 'Liitä leikepöydältä',
paste_request: 'Liitä pyyntö', paste_request: 'Liitä pyyntö',
@@ -155,9 +154,9 @@ window.localisation.fi = {
expiry: 'Vanheneminen', expiry: 'Vanheneminen',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Maksun varmenne', payment_proof: 'Maksun varmenne',
update_available: 'Saatavilla on päivitys versioon %{version}!', update_available: 'Saatavilla on päivitys versioon {version}!',
latest_update: latest_update:
'Käytössä oleva versio %{version}, on viimeisin saatavilla oleva.', 'Käytössä oleva versio {version}, on viimeisin saatavilla oleva.',
notifications: 'Tiedotteet', notifications: 'Tiedotteet',
no_notifications: 'Ei tiedotteita', no_notifications: 'Ei tiedotteita',
notifications_disabled: 'LNbits-tilatiedotteet on poistettu käytöstä.', notifications_disabled: 'LNbits-tilatiedotteet on poistettu käytöstä.',
@@ -247,8 +246,8 @@ window.localisation.fi = {
look_and_feel: 'Kieli ja värit', look_and_feel: 'Kieli ja värit',
language: 'Kieli', language: 'Kieli',
color_scheme: 'Väriteema', color_scheme: 'Väriteema',
extension_cost: 'Tämä julkaisu edellyttää vähintään %{cost} satsin maksua.', extension_cost: 'Tämä julkaisu edellyttää vähintään {cost} satsin maksua.',
extension_paid_sats: 'Olet jo maksanut %{paid_sats} satsia.', extension_paid_sats: 'Olet jo maksanut {paid_sats} satsia.',
release_details_error: 'Ei voi hakea julkaisun tietoja.', release_details_error: 'Ei voi hakea julkaisun tietoja.',
pay_from_wallet: 'Maksa lompakosta', pay_from_wallet: 'Maksa lompakosta',
show_qr: 'Näytä QR', show_qr: 'Näytä QR',
+11 -12
View File
@@ -37,9 +37,9 @@ window.localisation.fr = {
reset_defaults_tooltip: reset_defaults_tooltip:
'Supprimer tous les paramètres et les réinitialiser aux valeurs par défaut.', 'Supprimer tous les paramètres et les réinitialiser aux valeurs par défaut.',
download_backup: 'Télécharger la sauvegarde de la base de données', download_backup: 'Télécharger la sauvegarde de la base de données',
name_your_wallet: 'Nommez votre portefeuille %{name}', name_your_wallet: 'Nommez votre portefeuille {name}',
wallet_topup_ok: wallet_topup_ok:
'Succès de la création de fonds virtuels (%{amount} sats). Les paiements dépendent des fonds réels sur la source de financement.', 'Succès de la création de fonds virtuels ({amount} sats). Les paiements dépendent des fonds réels sur la source de financement.',
paste_invoice_label: paste_invoice_label:
'Coller une facture, une demande de paiement ou un code lnurl *', 'Coller une facture, une demande de paiement ou un code lnurl *',
lnbits_description: lnbits_description:
@@ -62,10 +62,10 @@ window.localisation.fr = {
view_github: 'Voir sur GitHub', view_github: 'Voir sur GitHub',
voidwallet_active: 'VoidWallet est actif! Paiements désactivés', voidwallet_active: 'VoidWallet est actif! Paiements désactivés',
use_with_caution: use_with_caution:
'UTILISER AVEC PRUDENCE - Le portefeuille %{name} est toujours en version BETA', 'UTILISER AVEC PRUDENCE - Le portefeuille {name} est toujours en version BETA',
service_fee: 'Frais de service : %{amount} % par transaction', service_fee: 'Frais de service : {amount} % par transaction',
service_fee_max: service_fee_max:
'Frais de service : %{amount} % par transaction (max %{max} sats)', 'Frais de service : {amount} % par transaction (max {max} sats)',
service_fee_tooltip: service_fee_tooltip:
"Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante", "Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante",
toggle_darkmode: 'Basculer le mode sombre', toggle_darkmode: 'Basculer le mode sombre',
@@ -76,7 +76,7 @@ window.localisation.fr = {
lnbits_version: 'Version de LNbits', lnbits_version: 'Version de LNbits',
runs_on: 'Fonctionne sur', runs_on: 'Fonctionne sur',
credit_hint: 'Appuyez sur Entrée pour créditer le compte', credit_hint: 'Appuyez sur Entrée pour créditer le compte',
credit_label: '%{denomination} à créditer', credit_label: '{denomination} à créditer',
paste: 'Coller', paste: 'Coller',
paste_from_clipboard: 'Coller depuis le presse-papiers', paste_from_clipboard: 'Coller depuis le presse-papiers',
paste_request: 'Coller la requête', paste_request: 'Coller la requête',
@@ -159,8 +159,8 @@ window.localisation.fr = {
expiry: 'Expiration', expiry: 'Expiration',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Preuve de paiement', payment_proof: 'Preuve de paiement',
update_available: 'Mise à jour %{version} disponible !', update_available: 'Mise à jour {version} disponible !',
latest_update: 'Vous êtes sur la dernière version %{version}.', latest_update: 'Vous êtes sur la dernière version {version}.',
notifications: 'Notifications', notifications: 'Notifications',
no_notifications: 'Aucune notification', no_notifications: 'Aucune notification',
notifications_disabled: notifications_disabled:
@@ -173,7 +173,7 @@ window.localisation.fr = {
'Si activé, il changera automatiquement votre source de financement en VoidWallet si LNbits envoie un signal de coupure. Vous devrez activer manuellement après une mise à jour.', 'Si activé, il changera automatiquement votre source de financement en VoidWallet si LNbits envoie un signal de coupure. Vous devrez activer manuellement après une mise à jour.',
killswitch_interval: 'Intervalle du Killswitch', killswitch_interval: 'Intervalle du Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
"À quelle fréquence la tâche de fond doit-elle vérifier le signal d'arrêt d'urgence LNBits provenant de la source de statut (en minutes).", "À quelle fréquence la tâche de fond doit-elle vérifier le signal d'arrêt d'urgence LNbits provenant de la source de statut (en minutes).",
enable_watchdog: 'Activer le Watchdog', enable_watchdog: 'Activer le Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Si elle est activée, elle changera automatiquement votre source de financement en VoidWallet si votre solde est inférieur au solde LNbits. Vous devrez activer manuellement après une mise à jour.', 'Si elle est activée, elle changera automatiquement votre source de financement en VoidWallet si votre solde est inférieur au solde LNbits. Vous devrez activer manuellement après une mise à jour.',
@@ -253,9 +253,8 @@ window.localisation.fr = {
look_and_feel: 'Apparence', look_and_feel: 'Apparence',
language: 'Langue', language: 'Langue',
color_scheme: 'Schéma de couleurs', color_scheme: 'Schéma de couleurs',
extension_cost: extension_cost: 'Cette version nécessite un paiement minimum de {cost} sats.',
'Cette version nécessite un paiement minimum de %{cost} sats.', extension_paid_sats: 'Vous avez déjà payé {paid_sats} sats.',
extension_paid_sats: 'Vous avez déjà payé %{paid_sats} sats.',
release_details_error: "Impossible d'obtenir les détails de la version.", release_details_error: "Impossible d'obtenir les détails de la version.",
pay_from_wallet: 'Payer depuis le portefeuille', pay_from_wallet: 'Payer depuis le portefeuille',
show_qr: 'Afficher le QR', show_qr: 'Afficher le QR',
+11 -11
View File
@@ -35,9 +35,9 @@ window.localisation.it = {
reset_defaults_tooltip: reset_defaults_tooltip:
'Cancella tutte le impostazioni e ripristina i valori predefiniti', 'Cancella tutte le impostazioni e ripristina i valori predefiniti',
download_backup: 'Scarica il backup del database', download_backup: 'Scarica il backup del database',
name_your_wallet: 'Dai un nome al tuo portafoglio %{name}', name_your_wallet: 'Dai un nome al tuo portafoglio {name}',
wallet_topup_ok: wallet_topup_ok:
'Operazione riuscita nella creazione di fondi virtuali (%{amount} sats). I pagamenti dipendono dai fondi effettivi sulla fonte di finanziamento.', 'Operazione riuscita nella creazione di fondi virtuali ({amount} sats). I pagamenti dipendono dai fondi effettivi sulla fonte di finanziamento.',
paste_invoice_label: paste_invoice_label:
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *', 'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
lnbits_description: lnbits_description:
@@ -59,10 +59,10 @@ window.localisation.it = {
donate: 'Donazioni', donate: 'Donazioni',
view_github: 'Visualizza su GitHub', view_github: 'Visualizza su GitHub',
voidwallet_active: 'VoidWallet è attivo! Pagamenti disabilitati', voidwallet_active: 'VoidWallet è attivo! Pagamenti disabilitati',
use_with_caution: 'USARE CON CAUTELA - %{name} portafoglio è ancora in BETA', use_with_caution: 'USARE CON CAUTELA - {name} portafoglio è ancora in BETA',
service_fee: 'Commissione di servizio: %{amount} % per transazione', service_fee: 'Commissione di servizio: {amount} % per transazione',
service_fee_max: service_fee_max:
'Commissione di servizio: %{amount} % per transazione (max %{max} sats)', 'Commissione di servizio: {amount} % per transazione (max {max} sats)',
service_fee_tooltip: service_fee_tooltip:
"Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita", "Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita",
toggle_darkmode: 'Attiva la modalità notturna', toggle_darkmode: 'Attiva la modalità notturna',
@@ -73,7 +73,7 @@ window.localisation.it = {
lnbits_version: 'Versione di LNbits', lnbits_version: 'Versione di LNbits',
runs_on: 'Esegue su', runs_on: 'Esegue su',
credit_hint: 'Premere Invio per accreditare i fondi', credit_hint: 'Premere Invio per accreditare i fondi',
credit_label: '%{denomination} da accreditare', credit_label: '{denomination} da accreditare',
paste: 'Incolla', paste: 'Incolla',
paste_from_clipboard: 'Incolla dagli appunti', paste_from_clipboard: 'Incolla dagli appunti',
paste_request: 'Richiesta di pagamento', paste_request: 'Richiesta di pagamento',
@@ -156,8 +156,8 @@ window.localisation.it = {
expiry: 'Scadenza', expiry: 'Scadenza',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Prova di pagamento', payment_proof: 'Prova di pagamento',
update_available: 'Aggiornamento %{version} disponibile!', update_available: 'Aggiornamento {version} disponibile!',
latest_update: 'Sei sulla versione più recente %{version}.', latest_update: 'Sei sulla versione più recente {version}.',
notifications: 'Notifiche', notifications: 'Notifiche',
no_notifications: 'Nessuna notifica', no_notifications: 'Nessuna notifica',
notifications_disabled: 'Le notifiche di stato di LNbits sono disattivate.', notifications_disabled: 'Le notifiche di stato di LNbits sono disattivate.',
@@ -169,7 +169,7 @@ window.localisation.it = {
'Se attivato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se LNbits invia un segnale di killswitch. Dovrai attivare manualmente dopo un aggiornamento.', 'Se attivato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se LNbits invia un segnale di killswitch. Dovrai attivare manualmente dopo un aggiornamento.',
killswitch_interval: 'Intervallo Killswitch', killswitch_interval: 'Intervallo Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
'Quanto spesso il compito in background dovrebbe controllare il segnale di killswitch LNBits dalla fonte di stato (in minuti).', 'Quanto spesso il compito in background dovrebbe controllare il segnale di killswitch LNbits dalla fonte di stato (in minuti).',
enable_watchdog: 'Attiva Watchdog', enable_watchdog: 'Attiva Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Se abilitato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se il tuo saldo è inferiore al saldo LNbits. Dovrai abilitarlo manualmente dopo un aggiornamento.', 'Se abilitato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se il tuo saldo è inferiore al saldo LNbits. Dovrai abilitarlo manualmente dopo un aggiornamento.',
@@ -251,8 +251,8 @@ window.localisation.it = {
language: 'Lingua', language: 'Lingua',
color_scheme: 'Schema dei colori', color_scheme: 'Schema dei colori',
extension_cost: extension_cost:
'Questa versione richiede un pagamento minimo di %{cost} satoshi.', 'Questa versione richiede un pagamento minimo di {cost} satoshi.',
extension_paid_sats: 'Hai già pagato %{paid_sats} sats.', extension_paid_sats: 'Hai già pagato {paid_sats} sats.',
release_details_error: 'Impossibile ottenere i dettagli della versione.', release_details_error: 'Impossibile ottenere i dettagli della versione.',
pay_from_wallet: 'Paga dal Portafoglio', pay_from_wallet: 'Paga dal Portafoglio',
show_qr: 'Mostra QR', show_qr: 'Mostra QR',
+11 -11
View File
@@ -34,9 +34,9 @@ window.localisation.jp = {
reset_defaults: 'リセット', reset_defaults: 'リセット',
reset_defaults_tooltip: 'すべての設定を削除してデフォルトに戻します。', reset_defaults_tooltip: 'すべての設定を削除してデフォルトに戻します。',
download_backup: 'データベースのバックアップをダウンロードする', download_backup: 'データベースのバックアップをダウンロードする',
name_your_wallet: 'あなたのウォレットの名前 %{name}', name_your_wallet: 'あなたのウォレットの名前 {name}',
wallet_topup_ok: wallet_topup_ok:
'仮想資金の作成に成功しました(%{amount} sats)。支払いは資金ソースの実際の資金に依存します。', '仮想資金の作成に成功しました({amount} sats)。支払いは資金ソースの実際の資金に依存します。',
paste_invoice_label: '請求書を貼り付けてください', paste_invoice_label: '請求書を貼り付けてください',
lnbits_description: lnbits_description:
'簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。', '簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
@@ -58,9 +58,9 @@ window.localisation.jp = {
view_github: 'GitHubで表示', view_github: 'GitHubで表示',
voidwallet_active: 'Voidwalletアクティブ', voidwallet_active: 'Voidwalletアクティブ',
use_with_caution: use_with_caution:
'注意して使用してください - %{name} ウォレットはまだベータ版です', '注意して使用してください - {name} ウォレットはまだベータ版です',
service_fee: '取引ごとのサービス手数料: %{amount} %', service_fee: '取引ごとのサービス手数料: {amount} %',
service_fee_max: '取引手数料:%{amount}%(最大%{max}サトシ)', service_fee_max: '取引手数料:{amount}%(最大{max}サトシ)',
service_fee_tooltip: 'LNbitsサーバー管理者が発生する送金ごとの手数料', service_fee_tooltip: 'LNbitsサーバー管理者が発生する送金ごとの手数料',
toggle_darkmode: 'ダークモードを切り替える', toggle_darkmode: 'ダークモードを切り替える',
payment_reactions: '支払いの反応', payment_reactions: '支払いの反応',
@@ -71,7 +71,7 @@ window.localisation.jp = {
runs_on: 'で実行', runs_on: 'で実行',
credit_hint: credit_hint:
'クレジットカードを使用して資金を追加するには、LNbitsを使用してください。', 'クレジットカードを使用して資金を追加するには、LNbitsを使用してください。',
credit_label: '%{denomination} をクレジットに', credit_label: '{denomination} をクレジットに',
paste: '貼り付け', paste: '貼り付け',
paste_from_clipboard: 'クリップボードから貼り付け', paste_from_clipboard: 'クリップボードから貼り付け',
paste_request: 'リクエストを貼り付ける', paste_request: 'リクエストを貼り付ける',
@@ -153,8 +153,8 @@ window.localisation.jp = {
expiry: '有効期限', expiry: '有効期限',
webhook: 'ウェブフック', webhook: 'ウェブフック',
payment_proof: '支払い証明', payment_proof: '支払い証明',
update_available: 'アップデート%{version}が利用可能です!', update_available: 'アップデート{version}が利用可能です!',
latest_update: 'あなたは最新バージョン%{version}を使用しています。', latest_update: 'あなたは最新バージョン{version}を使用しています。',
notifications: '通知', notifications: '通知',
no_notifications: '通知はありません', no_notifications: '通知はありません',
notifications_disabled: 'LNbitsステータス通知は無効です。', notifications_disabled: 'LNbitsステータス通知は無効です。',
@@ -166,7 +166,7 @@ window.localisation.jp = {
'有効にすると、LNbitsからキルスイッチ信号が送信された場合に自動的に資金源をVoidWalletに切り替えます。更新後には手動で有効にする必要があります。', '有効にすると、LNbitsからキルスイッチ信号が送信された場合に自動的に資金源をVoidWalletに切り替えます。更新後には手動で有効にする必要があります。',
killswitch_interval: 'キルスイッチ間隔', killswitch_interval: 'キルスイッチ間隔',
killswitch_interval_desc: killswitch_interval_desc:
'バックグラウンドタスクがステータスソースからLNBitsキルスイッチ信号を確認する頻度(分単位)。', 'バックグラウンドタスクがステータスソースからLNbitsキルスイッチ信号を確認する頻度(分単位)。',
enable_watchdog: 'ウォッチドッグを有効にする', enable_watchdog: 'ウォッチドッグを有効にする',
enable_watchdog_desc: enable_watchdog_desc:
'有効にすると、残高がLNbitsの残高より少ない場合に、資金源を自動的にVoidWalletに変更します。アップデート後は手動で有効にする必要があります。', '有効にすると、残高がLNbitsの残高より少ない場合に、資金源を自動的にVoidWalletに変更します。アップデート後は手動で有効にする必要があります。',
@@ -246,8 +246,8 @@ window.localisation.jp = {
look_and_feel: 'ルック・アンド・フィール', look_and_feel: 'ルック・アンド・フィール',
language: '言語', language: '言語',
color_scheme: 'カラースキーム', color_scheme: 'カラースキーム',
extension_cost: 'このリリースには最低 %{cost} サトシの支払いが必要です。', extension_cost: 'このリリースには最低 {cost} サトシの支払いが必要です。',
extension_paid_sats: 'すでに%{paid_sats} satsを支払いました。', extension_paid_sats: 'すでに{paid_sats} satsを支払いました。',
release_details_error: 'リリースの詳細を取得できません。', release_details_error: 'リリースの詳細を取得できません。',
pay_from_wallet: 'ウォレットから支払う', pay_from_wallet: 'ウォレットから支払う',
show_qr: 'QRを表示', show_qr: 'QRを表示',
+12 -12
View File
@@ -35,12 +35,12 @@ window.localisation.kr = {
reset_defaults_tooltip: reset_defaults_tooltip:
'설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.', '설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.',
download_backup: '데이터베이스 백업 다운로드', download_backup: '데이터베이스 백업 다운로드',
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요', name_your_wallet: '사용할 {name}지갑의 이름을 정하세요',
wallet_topup_ok: wallet_topup_ok:
'성공적으로 가상 자금을 생성했습니다 (%{amount} sats). 지급은 자금 원천의 실제 자금에 따라 달라집니다.', '성공적으로 가상 자금을 생성했습니다 ({amount} sats). 지급은 자금 원천의 실제 자금에 따라 달라집니다.',
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *', paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
lnbits_description: lnbits_description:
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.', '설정이 쉽고 가벼운 LNbits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNbits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNbits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNbits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNbits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNbits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기', export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기',
export_to_phone_desc: export_to_phone_desc:
'이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.', '이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.',
@@ -58,11 +58,11 @@ window.localisation.kr = {
donate: '기부', donate: '기부',
view_github: 'GitHub 페이지 보기', view_github: 'GitHub 페이지 보기',
voidwallet_active: 'VoidWallet이 활성화되었습니다! 결제가 불가능합니다.', voidwallet_active: 'VoidWallet이 활성화되었습니다! 결제가 불가능합니다.',
use_with_caution: '주의하세요 - %{name} 지갑은 아직 BETA 단계입니다.', use_with_caution: '주의하세요 - {name} 지갑은 아직 BETA 단계입니다.',
service_fee: '서비스 수수료: 거래액의 %{amount} %', service_fee: '서비스 수수료: 거래액의 {amount} %',
service_fee_max: '서비스 수수료: 거래액의 %{amount} % (최대 %{max} sats)', service_fee_max: '서비스 수수료: 거래액의 {amount} % (최대 {max} sats)',
service_fee_tooltip: service_fee_tooltip:
'지불 결제 시마다 LNBits 서버 관리자에게 납부되는 서비스 수수료', '지불 결제 시마다 LNbits 서버 관리자에게 납부되는 서비스 수수료',
toggle_darkmode: '다크 모드 전환', toggle_darkmode: '다크 모드 전환',
payment_reactions: '결제 반응', payment_reactions: '결제 반응',
view_swagger_docs: 'LNbits Swagger API 문서를 봅니다', view_swagger_docs: 'LNbits Swagger API 문서를 봅니다',
@@ -71,7 +71,7 @@ window.localisation.kr = {
lnbits_version: 'LNbits 버전', lnbits_version: 'LNbits 버전',
runs_on: 'Runs on', runs_on: 'Runs on',
credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요', credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요',
credit_label: '%{denomination} 단위로 충전하기', credit_label: '{denomination} 단위로 충전하기',
paste: '붙여넣기', paste: '붙여넣기',
paste_from_clipboard: '클립보드에서 붙여넣기', paste_from_clipboard: '클립보드에서 붙여넣기',
paste_request: '지불 요청 붙여넣기', paste_request: '지불 요청 붙여넣기',
@@ -153,8 +153,8 @@ window.localisation.kr = {
expiry: '만료', expiry: '만료',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Payment 증거', payment_proof: 'Payment 증거',
update_available: '%{version}으로 업데이트가 가능합니다.', update_available: '{version}으로 업데이트가 가능합니다.',
latest_update: '이미 %{version} 버전으로 업데이트되었습니다.', latest_update: '이미 {version} 버전으로 업데이트되었습니다.',
notifications: '알림', notifications: '알림',
no_notifications: '알림 없음', no_notifications: '알림 없음',
notifications_disabled: 'LNbits 상태 알림이 비활성화되었습니다.', notifications_disabled: 'LNbits 상태 알림이 비활성화되었습니다.',
@@ -243,8 +243,8 @@ window.localisation.kr = {
look_and_feel: '외관과 느낌', look_and_feel: '외관과 느낌',
language: '언어', language: '언어',
color_scheme: '색상 구성', color_scheme: '색상 구성',
extension_cost: '이 버전은 최소 %{cost} sats의 지불이 필요합니다.', extension_cost: '이 버전은 최소 {cost} sats의 지불이 필요합니다.',
extension_paid_sats: '당신은 이미 %{paid_sats} sats를 지불했습니다.', extension_paid_sats: '당신은 이미 {paid_sats} sats를 지불했습니다.',
release_details_error: '릴리스 세부 정보를 가져올 수 없습니다.', release_details_error: '릴리스 세부 정보를 가져올 수 없습니다.',
pay_from_wallet: '지갑에서 결제하다', pay_from_wallet: '지갑에서 결제하다',
show_qr: 'QR 보기', show_qr: 'QR 보기',
+11 -13
View File
@@ -36,9 +36,9 @@ window.localisation.nl = {
reset_defaults_tooltip: reset_defaults_tooltip:
'Wis alle instellingen en herstel de standaardinstellingen.', 'Wis alle instellingen en herstel de standaardinstellingen.',
download_backup: 'Databaseback-up downloaden', download_backup: 'Databaseback-up downloaden',
name_your_wallet: 'Geef je %{name} portemonnee een naam', name_your_wallet: 'Geef je {name} portemonnee een naam',
wallet_topup_ok: wallet_topup_ok:
'Succes met het aanmaken van virtuele fondsen (%{amount} sats). Betalingen zijn afhankelijk van de werkelijke fondsen op de financieringsbron.', 'Succes met het aanmaken van virtuele fondsen ({amount} sats). Betalingen zijn afhankelijk van de werkelijke fondsen op de financieringsbron.',
paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*', paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*',
lnbits_description: lnbits_description:
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.', 'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
@@ -60,10 +60,9 @@ window.localisation.nl = {
view_github: 'Bekijken op GitHub', view_github: 'Bekijken op GitHub',
voidwallet_active: 'VoidWallet is actief! Betalingen uitgeschakeld', voidwallet_active: 'VoidWallet is actief! Betalingen uitgeschakeld',
use_with_caution: use_with_caution:
'GEBRUIK MET VOORZICHTIGHEID - %{name} portemonnee is nog in BETA', 'GEBRUIK MET VOORZICHTIGHEID - {name} portemonnee is nog in BETA',
service_fee: 'Servicekosten: %{amount} % per transactie', service_fee: 'Servicekosten: {amount} % per transactie',
service_fee_max: service_fee_max: 'Servicekosten: {amount} % per transactie (max {max} sats)',
'Servicekosten: %{amount} % per transactie (max %{max} sats)',
service_fee_tooltip: service_fee_tooltip:
'Transactiekosten in rekening gebracht door de LNbits serverbeheerder per uitgaande transactie', 'Transactiekosten in rekening gebracht door de LNbits serverbeheerder per uitgaande transactie',
toggle_darkmode: 'Donkere modus aan/uit', toggle_darkmode: 'Donkere modus aan/uit',
@@ -74,7 +73,7 @@ window.localisation.nl = {
lnbits_version: 'LNbits-versie', lnbits_version: 'LNbits-versie',
runs_on: 'Draait op', runs_on: 'Draait op',
credit_hint: 'Druk op Enter om de rekening te crediteren', credit_hint: 'Druk op Enter om de rekening te crediteren',
credit_label: '%{denomination} te crediteren', credit_label: '{denomination} te crediteren',
paste: 'Plakken', paste: 'Plakken',
paste_from_clipboard: 'Plakken van klembord', paste_from_clipboard: 'Plakken van klembord',
paste_request: 'Verzoek plakken', paste_request: 'Verzoek plakken',
@@ -156,8 +155,8 @@ window.localisation.nl = {
expiry: 'Vervaldatum', expiry: 'Vervaldatum',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Betalingsbewijs', payment_proof: 'Betalingsbewijs',
update_available: 'Update %{version} beschikbaar!', update_available: 'Update {version} beschikbaar!',
latest_update: 'U bent op de nieuwste versie %{version}.', latest_update: 'U bent op de nieuwste versie {version}.',
notifications: 'Meldingen', notifications: 'Meldingen',
no_notifications: 'Geen meldingen', no_notifications: 'Geen meldingen',
notifications_disabled: 'LNbits-statusmeldingen zijn uitgeschakeld.', notifications_disabled: 'LNbits-statusmeldingen zijn uitgeschakeld.',
@@ -169,7 +168,7 @@ window.localisation.nl = {
'Indien ingeschakeld, zal het uw financieringsbron automatisch wijzigen naar VoidWallet als LNbits een killswitch-signaal verzendt. U zult het na een update handmatig moeten inschakelen.', 'Indien ingeschakeld, zal het uw financieringsbron automatisch wijzigen naar VoidWallet als LNbits een killswitch-signaal verzendt. U zult het na een update handmatig moeten inschakelen.',
killswitch_interval: 'Uitschakelschakelaar-interval', killswitch_interval: 'Uitschakelschakelaar-interval',
killswitch_interval_desc: killswitch_interval_desc:
'Hoe vaak de achtergrondtaak moet controleren op het LNBits killswitch signaal van de statusbron (in minuten).', 'Hoe vaak de achtergrondtaak moet controleren op het LNbits killswitch signaal van de statusbron (in minuten).',
enable_watchdog: 'Inschakelen Watchdog', enable_watchdog: 'Inschakelen Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Indien ingeschakeld, wordt uw betaalbron automatisch gewijzigd naar VoidWallet als uw saldo lager is dan het saldo van LNbits. U zult dit na een update handmatig moeten inschakelen.', 'Indien ingeschakeld, wordt uw betaalbron automatisch gewijzigd naar VoidWallet als uw saldo lager is dan het saldo van LNbits. U zult dit na een update handmatig moeten inschakelen.',
@@ -249,9 +248,8 @@ window.localisation.nl = {
look_and_feel: 'Uiterlijk en gedrag', look_and_feel: 'Uiterlijk en gedrag',
language: 'Taal', language: 'Taal',
color_scheme: 'Kleurenschema', color_scheme: 'Kleurenschema',
extension_cost: extension_cost: 'Deze release vereist een betaling van minimaal {cost} sats.',
'Deze release vereist een betaling van minimaal %{cost} sats.', extension_paid_sats: 'U heeft al {paid_sats} sats betaald.',
extension_paid_sats: 'U heeft al %{paid_sats} sats betaald.',
release_details_error: 'Kan de gegevens van de release niet ophalen.', release_details_error: 'Kan de gegevens van de release niet ophalen.',
pay_from_wallet: 'Betalen vanuit Portemonnee', pay_from_wallet: 'Betalen vanuit Portemonnee',
show_qr: 'Toon QR', show_qr: 'Toon QR',
+13 -13
View File
@@ -35,9 +35,9 @@ window.localisation.pi = {
reset_defaults_tooltip: reset_defaults_tooltip:
'Scuttle all settings and reset to Davy Jones Locker. Aye, start anew!', 'Scuttle all settings and reset to Davy Jones Locker. Aye, start anew!',
download_backup: 'Download database booty', download_backup: 'Download database booty',
name_your_wallet: 'Name yer %{name} treasure chest', name_your_wallet: 'Name yer {name} treasure chest',
wallet_topup_ok: wallet_topup_ok:
"Success creatin' virtual funds (%{amount} sats). Payments depend on actual funds on funding source.", "Success creatin' virtual funds ({amount} sats). Payments depend on actual funds on funding source.",
paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!', paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!',
lnbits_description: lnbits_description:
'Arr, easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.', 'Arr, easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.',
@@ -59,9 +59,9 @@ window.localisation.pi = {
view_github: 'View on GitHub and find treasures', view_github: 'View on GitHub and find treasures',
voidwallet_active: 'VoidWallet be active! Payments disabled', voidwallet_active: 'VoidWallet be active! Payments disabled',
use_with_caution: use_with_caution:
'USE WITH CAUTION - %{name} chest be still in BETA. Aye, be careful!', 'USE WITH CAUTION - {name} chest be still in BETA. Aye, be careful!',
service_fee: 'Service fee: %{amount} % per transaction', service_fee: 'Service fee: {amount} % per transaction',
service_fee_max: 'Service fee: %{amount} % per transaction (max %{max} sats)', service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
service_fee_tooltip: service_fee_tooltip:
"Service fee charged by the LNbits server admin per goin' transaction", "Service fee charged by the LNbits server admin per goin' transaction",
toggle_darkmode: 'Toggle Dark Mode, arr!', toggle_darkmode: 'Toggle Dark Mode, arr!',
@@ -72,7 +72,7 @@ window.localisation.pi = {
lnbits_version: 'LNbits version, arr!', lnbits_version: 'LNbits version, arr!',
runs_on: 'Runs on, matey', runs_on: 'Runs on, matey',
credit_hint: 'Press Enter to credit account and make it richer', credit_hint: 'Press Enter to credit account and make it richer',
credit_label: '%{denomination} to credit, arr!', credit_label: '{denomination} to credit, arr!',
paste: 'Stow', paste: 'Stow',
paste_from_clipboard: 'Paste from clipboard', paste_from_clipboard: 'Paste from clipboard',
paste_request: 'Paste Request and find treasures', paste_request: 'Paste Request and find treasures',
@@ -155,8 +155,8 @@ window.localisation.pi = {
expiry: 'Expiry like the food on a ship, ye landlubber', expiry: 'Expiry like the food on a ship, ye landlubber',
webhook: 'Webhook like a fishing line, arrr', webhook: 'Webhook like a fishing line, arrr',
payment_proof: 'Payment Proof like a seal of authenticity, argh', payment_proof: 'Payment Proof like a seal of authenticity, argh',
update_available: 'Update %{version} available, me matey!', update_available: 'Update {version} available, me matey!',
latest_update: "Ye be on th' latest version %{version}.", latest_update: "Ye be on th' latest version {version}.",
notifications: 'Notificashuns', notifications: 'Notificashuns',
no_notifications: "No noticin's", no_notifications: "No noticin's",
notifications_disabled: 'LNbits status notifications be disabled, arr!', notifications_disabled: 'LNbits status notifications be disabled, arr!',
@@ -168,7 +168,7 @@ window.localisation.pi = {
"If enabled it'll be changin' yer fundin' source to VoidWallet automatically if LNbits sends out a killswitch signal, ye will. Ye'll be needin' t' enable manually after an update, arr.", "If enabled it'll be changin' yer fundin' source to VoidWallet automatically if LNbits sends out a killswitch signal, ye will. Ye'll be needin' t' enable manually after an update, arr.",
killswitch_interval: 'Killswitch Interval', killswitch_interval: 'Killswitch Interval',
killswitch_interval_desc: killswitch_interval_desc:
"How oft th' background task should be checkin' fer th' LNBits killswitch signal from th' status source (in minutes).", "How oft th' background task should be checkin' fer th' LNbits killswitch signal from th' status source (in minutes).",
enable_watchdog: 'Enable Seadog', enable_watchdog: 'Enable Seadog',
enable_watchdog_desc: enable_watchdog_desc:
"If enabled, it will swap yer treasure source t' VoidWallet on its own if yer balance be lower than th' LNbits balance. Ye'll need t' enable by hand after an update.", "If enabled, it will swap yer treasure source t' VoidWallet on its own if yer balance be lower than th' LNbits balance. Ye'll need t' enable by hand after an update.",
@@ -197,7 +197,7 @@ window.localisation.pi = {
rate_limiter: 'Rate Limiter', rate_limiter: 'Rate Limiter',
wallet_limiter: 'Pouch Limitar', wallet_limiter: 'Pouch Limitar',
wallet_limit_max_withdraw_per_day: wallet_limit_max_withdraw_per_day:
'Max daily wallet withdrawal in sats (0 ter disable)', 'Max daily wallet withdrawal in sats (0 for no limit, -1 to block withdrawal)',
wallet_max_ballance: 'Purse max heaviness in sats (0 fer scuttle)', wallet_max_ballance: 'Purse max heaviness in sats (0 fer scuttle)',
wallet_limit_secs_between_trans: wallet_limit_secs_between_trans:
"Min secs 'tween transactions per wallet (0 to disable)", "Min secs 'tween transactions per wallet (0 to disable)",
@@ -206,7 +206,7 @@ window.localisation.pi = {
minute: 'minnit', minute: 'minnit',
second: 'second', second: 'second',
hour: 'hour', hour: 'hour',
disable_server_log: "Disabl' %{Server} Log", disable_server_log: "Disabl' {Server} Log",
enable_server_log: 'Enable Server Log', enable_server_log: 'Enable Server Log',
coming_soon: "Feature comin' soon", coming_soon: "Feature comin' soon",
session_has_expired: 'Yer session has expired. Please login again.', session_has_expired: 'Yer session has expired. Please login again.',
@@ -247,8 +247,8 @@ window.localisation.pi = {
language: 'Langwidge', language: 'Langwidge',
color_scheme: 'Colour Scheme', color_scheme: 'Colour Scheme',
extension_cost: extension_cost:
"This release be needin' a payment o' minimum %{cost} sats, arr.", "This release be needin' a payment o' minimum {cost} sats, arr.",
extension_paid_sats: 'Ye have already paid %{paid_sats} sats.', extension_paid_sats: 'Ye have already paid {paid_sats} sats.',
release_details_error: "Cannot get th' release details.", release_details_error: "Cannot get th' release details.",
pay_from_wallet: 'Pay from ye Wallet', pay_from_wallet: 'Pay from ye Wallet',
show_qr: 'Show QR', show_qr: 'Show QR',
+11 -11
View File
@@ -34,9 +34,9 @@ window.localisation.pl = {
reset_defaults: 'Powrót do ustawień domyślnych', reset_defaults: 'Powrót do ustawień domyślnych',
reset_defaults_tooltip: 'Wymaż wszystkie ustawienia i ustaw domyślne.', reset_defaults_tooltip: 'Wymaż wszystkie ustawienia i ustaw domyślne.',
download_backup: 'Pobierz kopię zapasową bazy danych', download_backup: 'Pobierz kopię zapasową bazy danych',
name_your_wallet: 'Nazwij swój portfel %{name}', name_your_wallet: 'Nazwij swój portfel {name}',
wallet_topup_ok: wallet_topup_ok:
'Sukces w tworzeniu wirtualnych środków (%{amount} sats). Płatności zależą od rzeczywistych środków na źródle finansowania.', 'Sukces w tworzeniu wirtualnych środków ({amount} sats). Płatności zależą od rzeczywistych środków na źródle finansowania.',
paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *', paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *',
lnbits_description: lnbits_description:
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR', 'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
@@ -57,10 +57,10 @@ window.localisation.pl = {
donate: 'Podaruj', donate: 'Podaruj',
view_github: 'Otwórz GitHub', view_github: 'Otwórz GitHub',
voidwallet_active: 'VoidWallet jest aktywny! Płatności są niemożliwe', voidwallet_active: 'VoidWallet jest aktywny! Płatności są niemożliwe',
use_with_caution: 'KORZYSTAJ Z ROZWAGĄ - portfel %{name} jest w wersji BETA', use_with_caution: 'KORZYSTAJ Z ROZWAGĄ - portfel {name} jest w wersji BETA',
service_fee: 'Opłata serwisowa: %{amount} % za transakcję', service_fee: 'Opłata serwisowa: {amount} % za transakcję',
service_fee_max: service_fee_max:
'Opłata serwisowa: %{amount} % za transakcję (maks %{max} sat)', 'Opłata serwisowa: {amount} % za transakcję (maks {max} sat)',
service_fee_tooltip: service_fee_tooltip:
'Opłata serwisowa pobierana przez administratora serwera LNbits za każdą wychodzącą transakcję', 'Opłata serwisowa pobierana przez administratora serwera LNbits za każdą wychodzącą transakcję',
toggle_darkmode: 'Tryb nocny', toggle_darkmode: 'Tryb nocny',
@@ -71,7 +71,7 @@ window.localisation.pl = {
lnbits_version: 'Wersja LNbits', lnbits_version: 'Wersja LNbits',
runs_on: 'Działa na', runs_on: 'Działa na',
credit_hint: 'Naciśnij Enter aby doładować konto', credit_hint: 'Naciśnij Enter aby doładować konto',
credit_label: '%{denomination} doładowanie', credit_label: '{denomination} doładowanie',
paste: 'Wklej', paste: 'Wklej',
paste_from_clipboard: 'Wklej ze schowka', paste_from_clipboard: 'Wklej ze schowka',
paste_request: 'Wklej żądanie', paste_request: 'Wklej żądanie',
@@ -153,8 +153,8 @@ window.localisation.pl = {
expiry: 'Wygasa', expiry: 'Wygasa',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Potwierdzenie płatności', payment_proof: 'Potwierdzenie płatności',
update_available: 'Aktualizacja %{version} dostępna!', update_available: 'Aktualizacja {version} dostępna!',
latest_update: 'Korzystasz z najnowszej wersji %{version}.', latest_update: 'Korzystasz z najnowszej wersji {version}.',
notifications: 'Powiadomienia', notifications: 'Powiadomienia',
no_notifications: 'Brak powiadomień', no_notifications: 'Brak powiadomień',
notifications_disabled: 'Powiadomienia o statusie LNbits są wyłączone.', notifications_disabled: 'Powiadomienia o statusie LNbits są wyłączone.',
@@ -166,7 +166,7 @@ window.localisation.pl = {
'Jeśli zostanie włączone, automatycznie zmieni źródło finansowania na VoidWallet, jeśli LNbits wyśle sygnał wyłączający. Po aktualizacji będziesz musiał włączyć to ręcznie.', 'Jeśli zostanie włączone, automatycznie zmieni źródło finansowania na VoidWallet, jeśli LNbits wyśle sygnał wyłączający. Po aktualizacji będziesz musiał włączyć to ręcznie.',
killswitch_interval: 'Interwał wyłącznika awaryjnego', killswitch_interval: 'Interwał wyłącznika awaryjnego',
killswitch_interval_desc: killswitch_interval_desc:
'Jak często zadanie w tle powinno sprawdzać sygnał wyłącznika awaryjnego LNBits ze źródła statusu (w minutach).', 'Jak często zadanie w tle powinno sprawdzać sygnał wyłącznika awaryjnego LNbits ze źródła statusu (w minutach).',
enable_watchdog: 'Włącz Watchdog', enable_watchdog: 'Włącz Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Jeśli zostanie włączone, automatycznie zmieni źródło finansowania na VoidWallet, jeśli saldo jest niższe niż saldo LNbits. Po aktualizacji trzeba będzie włączyć ręcznie.', 'Jeśli zostanie włączone, automatycznie zmieni źródło finansowania na VoidWallet, jeśli saldo jest niższe niż saldo LNbits. Po aktualizacji trzeba będzie włączyć ręcznie.',
@@ -246,8 +246,8 @@ window.localisation.pl = {
language: 'Język', language: 'Język',
color_scheme: 'Schemat kolorów', color_scheme: 'Schemat kolorów',
extension_cost: extension_cost:
'To niniejsze wydanie wymaga zapłaty minimalnej %{cost} satów.', 'To niniejsze wydanie wymaga zapłaty minimalnej {cost} satów.',
extension_paid_sats: 'Już zapłaciłeś %{paid_sats} satów.', extension_paid_sats: 'Już zapłaciłeś {paid_sats} satów.',
release_details_error: 'Nie można uzyskać szczegółów wydania.', release_details_error: 'Nie można uzyskać szczegółów wydania.',
pay_from_wallet: 'Zapłać z portfela', pay_from_wallet: 'Zapłać z portfela',
show_qr: 'Pokaż kod QR', show_qr: 'Pokaż kod QR',
+11 -11
View File
@@ -35,9 +35,9 @@ window.localisation.pt = {
reset_defaults_tooltip: reset_defaults_tooltip:
'Apagar todas as configurações e redefinir para os padrões.', 'Apagar todas as configurações e redefinir para os padrões.',
download_backup: 'Fazer backup da base de dados', download_backup: 'Fazer backup da base de dados',
name_your_wallet: 'Nomeie sua carteira %{name}', name_your_wallet: 'Nomeie sua carteira {name}',
wallet_topup_ok: wallet_topup_ok:
'Sucesso ao criar fundos virtuais (%{amount} sats). Os pagamentos dependem dos fundos reais na fonte de financiamento.', 'Sucesso ao criar fundos virtuais ({amount} sats). Os pagamentos dependem dos fundos reais na fonte de financiamento.',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *', paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description: lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.', 'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
@@ -58,10 +58,10 @@ window.localisation.pt = {
donate: 'Doar', donate: 'Doar',
view_github: 'Ver no GitHub', view_github: 'Ver no GitHub',
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados', voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
use_with_caution: 'USE COM CAUTELA - a carteira %{name} ainda está em BETA', use_with_caution: 'USE COM CAUTELA - a carteira {name} ainda está em BETA',
service_fee: 'Taxa de serviço: %{amount} % por transação', service_fee: 'Taxa de serviço: {amount} % por transação',
service_fee_max: service_fee_max:
'Taxa de serviço: %{amount} % por transação (máximo de %{max} sats)', 'Taxa de serviço: {amount} % por transação (máximo de {max} sats)',
service_fee_tooltip: service_fee_tooltip:
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída', 'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
toggle_darkmode: 'Alternar modo escuro', toggle_darkmode: 'Alternar modo escuro',
@@ -72,7 +72,7 @@ window.localisation.pt = {
lnbits_version: 'Versão do LNbits', lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em', runs_on: 'Executa em',
credit_hint: 'Pressione Enter para creditar a conta', credit_hint: 'Pressione Enter para creditar a conta',
credit_label: '%{denomination} para creditar', credit_label: '{denomination} para creditar',
paste: 'Colar', paste: 'Colar',
paste_from_clipboard: 'Colar da área de transferência', paste_from_clipboard: 'Colar da área de transferência',
paste_request: 'Colar Pedido', paste_request: 'Colar Pedido',
@@ -154,8 +154,8 @@ window.localisation.pt = {
expiry: 'Validade', expiry: 'Validade',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Comprovativo de pagamento', payment_proof: 'Comprovativo de pagamento',
update_available: 'Atualização %{version} disponível!', update_available: 'Atualização {version} disponível!',
latest_update: 'Você está na última versão %{version}.', latest_update: 'Você está na última versão {version}.',
notifications: 'Notificações', notifications: 'Notificações',
no_notifications: 'Sem notificações', no_notifications: 'Sem notificações',
notifications_disabled: notifications_disabled:
@@ -168,7 +168,7 @@ window.localisation.pt = {
'Se ativado, ele mudará sua fonte de financiamento para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.', 'Se ativado, ele mudará sua fonte de financiamento para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.',
killswitch_interval: 'Intervalo do Killswitch', killswitch_interval: 'Intervalo do Killswitch',
killswitch_interval_desc: killswitch_interval_desc:
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNBits proveniente da fonte de status (em minutos).', 'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNbits proveniente da fonte de status (em minutos).',
enable_watchdog: 'Ativar Watchdog', enable_watchdog: 'Ativar Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Se ativado, mudará automaticamente a sua fonte de financiamento para VoidWallet caso o seu saldo seja inferior ao saldo LNbits. Você precisará ativar manualmente após uma atualização.', 'Se ativado, mudará automaticamente a sua fonte de financiamento para VoidWallet caso o seu saldo seja inferior ao saldo LNbits. Você precisará ativar manualmente após uma atualização.',
@@ -246,8 +246,8 @@ window.localisation.pt = {
look_and_feel: 'Aparência e Sensação', look_and_feel: 'Aparência e Sensação',
language: 'Idioma', language: 'Idioma',
color_scheme: 'Esquema de Cores', color_scheme: 'Esquema de Cores',
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.', extension_cost: 'Este lançamento requer um pagamento mínimo de {cost} sats.',
extension_paid_sats: 'Você já pagou %{paid_sats} sats.', extension_paid_sats: 'Você já pagou {paid_sats} sats.',
release_details_error: 'Não é possível obter os detalhes da versão.', release_details_error: 'Não é possível obter os detalhes da versão.',
pay_from_wallet: 'Pague da Carteira', pay_from_wallet: 'Pague da Carteira',
show_qr: 'Exibir QR', show_qr: 'Exibir QR',
+10 -10
View File
@@ -34,9 +34,9 @@ window.localisation.sk = {
reset_defaults: 'Obnoviť predvolené', reset_defaults: 'Obnoviť predvolené',
reset_defaults_tooltip: 'Odstrániť všetky nastavenia a obnoviť predvolené.', reset_defaults_tooltip: 'Odstrániť všetky nastavenia a obnoviť predvolené.',
download_backup: 'Stiahnuť zálohu databázy', download_backup: 'Stiahnuť zálohu databázy',
name_your_wallet: 'Pomenujte vašu %{name} peňaženku', name_your_wallet: 'Pomenujte vašu {name} peňaženku',
wallet_topup_ok: wallet_topup_ok:
'Úspešne vytvorené virtuálne prostriedky (%{amount} sats). Platby závisia od skutočných prostriedkov v zdroji financovania.', 'Úspešne vytvorené virtuálne prostriedky ({amount} sats). Platby závisia od skutočných prostriedkov v zdroji financovania.',
paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *', paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *',
lnbits_description: lnbits_description:
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania Lightning Network a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.', 'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania Lightning Network a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.',
@@ -57,10 +57,10 @@ window.localisation.sk = {
donate: 'Prispieť', donate: 'Prispieť',
view_github: 'Zobraziť na GitHube', view_github: 'Zobraziť na GitHube',
voidwallet_active: 'VoidWallet je aktívny! Platby zakázané', voidwallet_active: 'VoidWallet je aktívny! Platby zakázané',
use_with_caution: 'POUŽÍVAJTE OPATRNE - %{name} peňaženka je stále v BETE', use_with_caution: 'POUŽÍVAJTE OPATRNE - {name} peňaženka je stále v BETE',
service_fee: 'Servisný poplatok: %{amount} % za transakciu', service_fee: 'Servisný poplatok: {amount} % za transakciu',
service_fee_max: service_fee_max:
'Servisný poplatok: %{amount} % za transakciu (max %{max} satoshi)', 'Servisný poplatok: {amount} % za transakciu (max {max} satoshi)',
service_fee_tooltip: service_fee_tooltip:
'Servisný poplatok účtovaný správcom LNbits servera za odchádzajúcu transakciu', 'Servisný poplatok účtovaný správcom LNbits servera za odchádzajúcu transakciu',
toggle_darkmode: 'Prepnúť Tmavý režim', toggle_darkmode: 'Prepnúť Tmavý režim',
@@ -71,7 +71,7 @@ window.localisation.sk = {
lnbits_version: 'Verzia LNbits', lnbits_version: 'Verzia LNbits',
runs_on: 'Beží na', runs_on: 'Beží na',
credit_hint: 'Stlačte Enter pre pripísanie na účet', credit_hint: 'Stlačte Enter pre pripísanie na účet',
credit_label: '%{denomination} na pripísanie', credit_label: '{denomination} na pripísanie',
paste: 'Vložiť', paste: 'Vložiť',
paste_from_clipboard: 'Vložiť zo schránky', paste_from_clipboard: 'Vložiť zo schránky',
paste_request: 'Vložiť požiadavku', paste_request: 'Vložiť požiadavku',
@@ -153,8 +153,8 @@ window.localisation.sk = {
expiry: 'Expirácia', expiry: 'Expirácia',
webhook: 'Webhook', webhook: 'Webhook',
payment_proof: 'Dôkaz platby', payment_proof: 'Dôkaz platby',
update_available: 'Dostupná aktualizácia %{version}!', update_available: 'Dostupná aktualizácia {version}!',
latest_update: 'Máte najnovšiu verziu %{version}.', latest_update: 'Máte najnovšiu verziu {version}.',
notifications: 'Notifikácie', notifications: 'Notifikácie',
no_notifications: 'Žiadne notifikácie', no_notifications: 'Žiadne notifikácie',
notifications_disabled: 'Notifikácie stavu LNbits sú zakázané.', notifications_disabled: 'Notifikácie stavu LNbits sú zakázané.',
@@ -245,8 +245,8 @@ window.localisation.sk = {
look_and_feel: 'Vzhľad a dojem', look_and_feel: 'Vzhľad a dojem',
language: 'Jazyk', language: 'Jazyk',
color_scheme: 'Farebná schéma', color_scheme: 'Farebná schéma',
extension_cost: 'Táto verzia vyžaduje minimálnu platbu %{cost} satoshi.', extension_cost: 'Táto verzia vyžaduje minimálnu platbu {cost} satoshi.',
extension_paid_sats: 'Už ste zaplatili %{paid_sats} sats.', extension_paid_sats: 'Už ste zaplatili {paid_sats} sats.',
release_details_error: 'Nepodarilo sa získať podrobnosti o vydaní.', release_details_error: 'Nepodarilo sa získať podrobnosti o vydaní.',
pay_from_wallet: 'Zaplatiť z peňaženky', pay_from_wallet: 'Zaplatiť z peňaženky',
show_qr: 'Zobraziť QR', show_qr: 'Zobraziť QR',
+11 -11
View File
@@ -34,9 +34,9 @@ window.localisation.we = {
reset_defaults: 'Ailosod i`r rhagosodiadau', reset_defaults: 'Ailosod i`r rhagosodiadau',
reset_defaults_tooltip: 'Dileu pob gosodiad ac ailosod i`r rhagosodiadau.', reset_defaults_tooltip: 'Dileu pob gosodiad ac ailosod i`r rhagosodiadau.',
download_backup: 'Lawrlwytho copi wrth gefn cronfa ddata', download_backup: 'Lawrlwytho copi wrth gefn cronfa ddata',
name_your_wallet: 'Enwch eich waled %{name}', name_your_wallet: 'Enwch eich waled {name}',
wallet_topup_ok: wallet_topup_ok:
"Llwyddiant wrth greu cronfeydd rhithwir (%{amount} sats). Mae taliadau'n dibynnu ar gronfeydd gwirioneddol ar y ffynhonnell cyllido.", "Llwyddiant wrth greu cronfeydd rhithwir ({amount} sats). Mae taliadau'n dibynnu ar gronfeydd gwirioneddol ar y ffynhonnell cyllido.",
paste_invoice_label: 'Gludwch anfoneb, cais am daliad neu god lnurl *', paste_invoice_label: 'Gludwch anfoneb, cais am daliad neu god lnurl *',
lnbits_description: lnbits_description:
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.', 'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.',
@@ -58,10 +58,10 @@ window.localisation.we = {
view_github: 'Gweld ar GitHub', view_github: 'Gweld ar GitHub',
voidwallet_active: voidwallet_active:
' Mae VoidWallet yn weithredol! Taliadau wedi`u hanalluogi', ' Mae VoidWallet yn weithredol! Taliadau wedi`u hanalluogi',
use_with_caution: 'DEFNYDDIO GYDA GOFAL - mae waled %{name} yn dal yn BETA', use_with_caution: 'DEFNYDDIO GYDA GOFAL - mae waled {name} yn dal yn BETA',
service_fee: 'Ffi gwasanaeth: %{amount} % y trafodiad', service_fee: 'Ffi gwasanaeth: {amount} % y trafodiad',
service_fee_max: service_fee_max:
'Ffi gwasanaeth: %{amount} % y trafodiad (uchafswm %{max} sats)', 'Ffi gwasanaeth: {amount} % y trafodiad (uchafswm {max} sats)',
service_fee_tooltip: service_fee_tooltip:
"Ffi gwasanaeth a godir gan weinyddwr gweinydd LNbits ym mhob trafodiad sy'n mynd allan", "Ffi gwasanaeth a godir gan weinyddwr gweinydd LNbits ym mhob trafodiad sy'n mynd allan",
toggle_darkmode: 'Toglo Modd Tywyll', toggle_darkmode: 'Toglo Modd Tywyll',
@@ -72,7 +72,7 @@ window.localisation.we = {
lnbits_version: 'Fersiwn LNbits', lnbits_version: 'Fersiwn LNbits',
runs_on: 'Yn rhedeg ymlaen', runs_on: 'Yn rhedeg ymlaen',
credit_hint: 'Pwyswch Enter i gyfrif credyd', credit_hint: 'Pwyswch Enter i gyfrif credyd',
credit_label: '%{denomination} i gredyd', credit_label: '{denomination} i gredyd',
paste: 'Gludo', paste: 'Gludo',
paste_from_clipboard: "Gludo o'r clipfwrdd", paste_from_clipboard: "Gludo o'r clipfwrdd",
paste_request: 'Gludo Cais', paste_request: 'Gludo Cais',
@@ -153,8 +153,8 @@ window.localisation.we = {
expiry: 'dod i ben', expiry: 'dod i ben',
webhook: 'bachyn we', webhook: 'bachyn we',
payment_proof: 'prawf taliad', payment_proof: 'prawf taliad',
update_available: 'Diweddariad %{version} ar gael!', update_available: 'Diweddariad {version} ar gael!',
latest_update: 'Rydych chi ar y fersiwn diweddaraf %{version}.', latest_update: 'Rydych chi ar y fersiwn diweddaraf {version}.',
notifications: 'Hysbysiadau', notifications: 'Hysbysiadau',
no_notifications: 'Dim hysbysiadau', no_notifications: 'Dim hysbysiadau',
notifications_disabled: "Hysbysiadau statws LNbits wedi'u analluogi.", notifications_disabled: "Hysbysiadau statws LNbits wedi'u analluogi.",
@@ -166,7 +166,7 @@ window.localisation.we = {
'Os bydd yn galluogi, bydd yn newid eich ffynhonnell arian i VoidWallet yn awtomatig os bydd LNbits yn anfon arwydd killswitch. Bydd angen i chi alluogi â llaw ar ôl diweddariad.', 'Os bydd yn galluogi, bydd yn newid eich ffynhonnell arian i VoidWallet yn awtomatig os bydd LNbits yn anfon arwydd killswitch. Bydd angen i chi alluogi â llaw ar ôl diweddariad.',
killswitch_interval: 'Amlder Cyllell Dorri', killswitch_interval: 'Amlder Cyllell Dorri',
killswitch_interval_desc: killswitch_interval_desc:
"Pa mor aml y dylai'r dasg gefndir wirio am signal killswitch LNBits o'r ffynhonnell statws (mewn munudau).", "Pa mor aml y dylai'r dasg gefndir wirio am signal killswitch LNbits o'r ffynhonnell statws (mewn munudau).",
enable_watchdog: 'Galluogi Watchdog', enable_watchdog: 'Galluogi Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'Os bydd yn cael ei alluogi bydd yn newid eich ffynhonnell ariannu i VoidWallet yn awtomatig os bydd eich balans yn is na balans LNbits. Bydd angen i chi alluogi â llaw ar ôl diweddariad.', 'Os bydd yn cael ei alluogi bydd yn newid eich ffynhonnell ariannu i VoidWallet yn awtomatig os bydd eich balans yn is na balans LNbits. Bydd angen i chi alluogi â llaw ar ôl diweddariad.',
@@ -245,8 +245,8 @@ window.localisation.we = {
look_and_feel: 'Edrych a Theimlo', look_and_feel: 'Edrych a Theimlo',
language: 'Iaith', language: 'Iaith',
color_scheme: 'Cynllun Lliw', color_scheme: 'Cynllun Lliw',
extension_cost: "Mae'r rhyddhad hwn yn gofyn am daliad o leiaf %{cost} sats.", extension_cost: "Mae'r rhyddhad hwn yn gofyn am daliad o leiaf {cost} sats.",
extension_paid_sats: 'Rydych chi eisoes wedi talu %{paid_sats} sats.', extension_paid_sats: 'Rydych chi eisoes wedi talu {paid_sats} sats.',
release_details_error: 'Methu cael manylion y rhyddhau.', release_details_error: 'Methu cael manylion y rhyddhau.',
pay_from_wallet: "Talu o'r Waled", pay_from_wallet: "Talu o'r Waled",
show_qr: 'Dangos QR', show_qr: 'Dangos QR',
+10
View File
@@ -0,0 +1,10 @@
<svg width="46.626mm" height="12.965mm" version="1.1" viewBox="0 0 46.626 12.965" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.85851 0 0 .85851 -56.357 -42.14)">
<g transform="matrix(.26458 0 0 .26458 69.488 50.153)" style="fill:none">
<path d="m14.462 28.768-.0035.0072h-12.787c-.4776 0-.8756-.1467-1.194-.4401s-.4776-.6619-.4776-1.1055c0-.4723.20262-.9589.60786-1.4598l20.342-24.837c.3763-.48659.7924-.77997 1.2482-.88015.4559-.10018.8648-.057246 1.2266.1288.3618.18605.6151.49016.7598.91235.1448.42218.1086.92666-.1085 1.5134l-6.643 17.625h1.1057l.0035-.0072h12.787c.4776 0 .8756.1467 1.194.4401s.4776.6619.4776 1.1055c0 .4723-.2026.9589-.6079 1.4598l-20.342 24.837c-.3763.4865-.7924.7799-1.2482.8801-.4559.1002-.86479.0573-1.2266-.1288-.36182-.186-.61509-.4902-.75982-.9123-.14473-.4222-.10855-.9267.10854-1.5135l6.643-17.624z" clip-rule="evenodd" fill="#fff" fill-rule="evenodd" style="fill:#fff"/>
</g>
<g transform="matrix(.26458 0 0 .26458 81.643 50.938)" style="fill:none">
<path d="m.92 42h19.488c7.672 0 12.488-4.144 12.488-11.312 0-5.152-2.072-7.952-5.264-9.464v-.224c1.96-1.4 3.808-3.752 3.808-8.008 0-6.44-4.76-10.192-11.76-10.192h-18.76zm8.736-24.08v-7.392h8.736c2.968 0 4.144 1.568 4.144 3.696s-1.176 3.696-4.144 3.696zm0 16.296v-8.68h9.296c3.808 0 4.984 2.352 4.984 4.312 0 2.352-1.232 4.368-4.984 4.368zm40.657 8.288c9.52 0 14.616-6.384 14.616-15.12 0-8.792-5.208-15.176-14.616-15.176s-14.616 6.384-14.616 15.176 5.096 15.12 14.616 15.12zm0-7.56c-3.528 0-5.824-2.24-5.824-7.56 0-5.264 2.24-7.616 5.824-7.616s5.824 2.296 5.824 7.616-2.24 7.56-5.824 7.56zm18.831 7.056h8.624v-41.44h-8.624zm26.552.504c2.744 0 5.0404-.504 5.9364-.84v-7.168h-.56c-.896.336-1.8484.504-2.8004.504-2.576 0-3.752-1.176-3.752-3.92v-11.032h7.0004v-7.336h-7.0004v-7.672h-.56l-7.672 1.568v6.104h-5.152v7.336h4.76v13.104c0 5.32 3.024 9.352 9.8 9.352zm10.397-.504h24.192v-7.336h-13.552v-.224l13.44-14.448v-7.28h-24.024v7.336h13.44v.224l-13.496 14.504z" fill="#fff" style="fill:#fff"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="46.626mm" height="12.965mm" version="1.1" viewBox="0 0 46.626 12.965" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.85851 0 0 .85851 -56.357 -42.14)">
<g transform="matrix(.26458 0 0 .26458 69.488 50.153)">
<path d="m14.462 28.768-.0035.0072h-12.787c-.4776 0-.8756-.1467-1.194-.4401s-.4776-.6619-.4776-1.1055c0-.4723.20262-.9589.60786-1.4598l20.342-24.837c.3763-.48659.7924-.77997 1.2482-.88015.4559-.10018.8648-.057246 1.2266.1288.3618.18605.6151.49016.7598.91235.1448.42218.1086.92666-.1085 1.5134l-6.643 17.625h1.1057l.0035-.0072h12.787c.4776 0 .8756.1467 1.194.4401s.4776.6619.4776 1.1055c0 .4723-.2026.9589-.6079 1.4598l-20.342 24.837c-.3763.4865-.7924.7799-1.2482.8801-.4559.1002-.86479.0573-1.2266-.1288-.36182-.186-.61509-.4902-.75982-.9123-.14473-.4222-.10855-.9267.10854-1.5135l6.643-17.624z" clip-rule="evenodd" fill="#fff" fill-rule="evenodd" style="fill:#000"/>
</g>
<g transform="matrix(.26458 0 0 .26458 81.643 50.938)">
<path d="m.92 42h19.488c7.672 0 12.488-4.144 12.488-11.312 0-5.152-2.072-7.952-5.264-9.464v-.224c1.96-1.4 3.808-3.752 3.808-8.008 0-6.44-4.76-10.192-11.76-10.192h-18.76zm8.736-24.08v-7.392h8.736c2.968 0 4.144 1.568 4.144 3.696s-1.176 3.696-4.144 3.696zm0 16.296v-8.68h9.296c3.808 0 4.984 2.352 4.984 4.312 0 2.352-1.232 4.368-4.984 4.368zm40.657 8.288c9.52 0 14.616-6.384 14.616-15.12 0-8.792-5.208-15.176-14.616-15.176s-14.616 6.384-14.616 15.176 5.096 15.12 14.616 15.12zm0-7.56c-3.528 0-5.824-2.24-5.824-7.56 0-5.264 2.24-7.616 5.824-7.616s5.824 2.296 5.824 7.616-2.24 7.56-5.824 7.56zm18.831 7.056h8.624v-41.44h-8.624zm26.552.504c2.744 0 5.0404-.504 5.9364-.84v-7.168h-.56c-.896.336-1.8484.504-2.8004.504-2.576 0-3.752-1.176-3.752-3.92v-11.032h7.0004v-7.336h-7.0004v-7.672h-.56l-7.672 1.568v6.104h-5.152v7.336h4.76v13.104c0 5.32 3.024 9.352 9.8 9.352zm10.397-.504h24.192v-7.336h-13.552v-.224l13.44-14.448v-7.28h-24.024v7.336h13.44v.224l-13.496 14.504z" fill="#fff" style="fill:#000"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.04502 6.46189C3.1858 7.47829 2.51519 8.66234 2.10654 9.96165C4.43272 9.43773 7.52382 9.31199 9.27369 9.17577C9.81856 6.93342 11.3694 5.22546 13.968 5.34072C15.0891 5.39311 16.0741 6.11611 16.7552 7.22681C17.2582 6.69242 17.9288 6.33615 18.8194 6.21042C18.8823 6.21042 19.0185 6.19994 19.1233 6.19994C17.2791 4.1881 14.6491 2.90975 11.7047 2.90975C11.1703 2.90975 10.6464 2.96214 10.1224 3.04597C10.0177 3.04597 9.88143 3.06693 9.7033 3.10884C9.69282 3.10884 9.67187 3.10884 9.66139 3.10884C9.65091 3.10884 9.64043 3.10884 9.61948 3.10884C7.58669 3.60132 6.05685 3.20314 5.18716 2.0191C5.09285 1.89336 4.72611 1.33801 4.61085 0.363525C3.97167 1.02366 3.55254 2.1134 3.9088 3.27649C4.19172 4.19858 4.72611 4.69106 5.30242 4.97398C4.42224 5.02637 3.68876 4.85871 2.95528 4.30337C2.52567 3.97854 2.25323 3.62228 1.80267 2.68971C1.38353 3.34984 1.42545 4.08332 1.50927 4.46054C1.61406 4.9635 1.87601 5.52933 2.21132 5.85415C2.72476 6.35711 3.45824 6.47237 4.0555 6.46189H4.04502Z" fill="white"/>
<path d="M13.9782 15.1276C15.2803 15.1276 16.3358 13.3215 16.3358 11.0935C16.3358 8.86547 15.2803 7.05933 13.9782 7.05933C12.6761 7.05933 11.6206 8.86547 11.6206 11.0935C11.6206 13.3215 12.6761 15.1276 13.9782 15.1276Z" fill="white"/>
<path d="M19.165 14.1532C20.1835 14.1532 21.0092 12.7177 21.0092 10.9468C21.0092 9.17601 20.1835 7.74048 19.165 7.74048C18.1465 7.74048 17.3208 9.17601 17.3208 10.9468C17.3208 12.7177 18.1465 14.1532 19.165 14.1532Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0339 31.9777C10.0445 31.5085 10.2194 30.0309 11.7152 27.0729C12.3753 25.7841 13.8737 24.0028 14.9949 23.0387C15.5922 22.5148 16.1894 22.0747 16.7762 21.6661C17.0801 21.488 17.363 21.2993 17.6354 21.1003C22.5434 18.0911 26.876 18.7195 30.4704 19.2408L30.5761 19.2561C30.5761 19.2561 31.4354 16.6994 27.8937 15.3791C25.9657 14.6666 23.6919 14.0903 21.7953 13.6921C21.5229 14.1846 21.1771 14.6037 20.7684 14.9181C20.7628 14.9223 20.7571 14.9266 20.7512 14.931C20.4473 15.1594 19.7267 15.7009 18.4213 15.5468C17.7507 15.4629 17.2373 15.1905 16.8286 14.7923C16.137 15.9345 15.1416 16.6784 13.989 16.7832C10.7931 17.0556 9.17945 14.3732 9.07466 11.4078C6.67513 11.6384 3.4059 13.1158 1.6665 13.975L1.6755 29.386C3.89486 30.2461 7.22426 31.3076 10.0339 31.9777Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+96 -24
View File
@@ -1,6 +1,6 @@
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
user: null, user: null,
@@ -13,25 +13,63 @@ new Vue({
'confettiStars' 'confettiStars'
], ],
tab: 'user', tab: 'user',
passwordData: { credentialsData: {
show: false, show: false,
oldPassword: null, oldPassword: null,
newPassword: null, newPassword: null,
newPasswordRepeat: null newPasswordRepeat: null,
username: null,
pubkey: null
} }
} }
}, },
methods: { methods: {
activeLanguage: function (lang) { activeLanguage: function (lang) {
return window.i18n.locale === lang return window.i18n.global.locale === lang
}, },
changeLanguage: function (newValue) { changeLanguage: function (newValue) {
window.i18n.locale = newValue window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue) this.$q.localStorage.set('lnbits.lang', newValue)
}, },
toggleDarkMode: function () { toggleDarkMode: function () {
this.$q.dark.toggle() this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive) this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
if (!this.$q.dark.isActive && this.gradientChoice) {
this.toggleGradient()
}
},
applyGradient: function () {
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
if (this.gradientChoice) {
if (!this.$q.dark.isActive) {
this.toggleDarkMode()
}
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
document.body.style.setProperty(
'background-image',
gradientStyle,
'important'
)
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important`
const style = document.createElement('style')
style.innerHTML =
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
`[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
document.head.appendChild(style)
this.$q.localStorage.set('lnbits.gradientBg', true)
} else {
this.$q.localStorage.set('lnbits.gradientBg', false)
}
},
toggleGradient: function () {
this.gradientChoice = !this.gradientChoice
this.applyGradient()
if (!this.gradientChoice) {
window.location.reload()
}
this.gradientChoice = this.$q.localStorage.getItem('lnbits.gradientBg')
}, },
reactionChoiceFunc: function () { reactionChoiceFunc: function () {
this.$q.localStorage.set('lnbits.reactions', this.reactionChoice) this.$q.localStorage.set('lnbits.reactions', this.reactionChoice)
@@ -39,6 +77,10 @@ new Vue({
changeColor: function (newValue) { changeColor: function (newValue) {
document.body.setAttribute('data-theme', newValue) document.body.setAttribute('data-theme', newValue)
this.$q.localStorage.set('lnbits.theme', newValue) this.$q.localStorage.set('lnbits.theme', newValue)
this.setColors()
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.applyGradient()
}
}, },
updateAccount: async function () { updateAccount: async function () {
try { try {
@@ -50,11 +92,12 @@ new Vue({
user_id: this.user.id, user_id: this.user.id,
username: this.user.username, username: this.user.username,
email: this.user.email, email: this.user.email,
config: this.user.config extra: this.user.extra
} }
) )
this.user = data this.user = data
this.$q.notify({ this.hasUsername = !!data.username
Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Account updated.' message: 'Account updated.'
}) })
@@ -62,11 +105,19 @@ new Vue({
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
}, },
disableUpdatePassword: function () {
return (
!this.credentialsData.newPassword ||
!this.credentialsData.newPasswordRepeat ||
this.credentialsData.newPassword !==
this.credentialsData.newPasswordRepeat
)
},
updatePassword: async function () { updatePassword: async function () {
if (!this.user.username) { if (!this.credentialsData.username) {
this.$q.notify({ Quasar.Notify.create({
type: 'warning', type: 'warning',
message: 'Please set a username first.' message: 'Please set a username.'
}) })
return return
} }
@@ -77,15 +128,16 @@ new Vue({
null, null,
{ {
user_id: this.user.id, user_id: this.user.id,
username: this.user.username, username: this.credentialsData.username,
password_old: this.passwordData.oldPassword, password_old: this.credentialsData.oldPassword,
password: this.passwordData.newPassword, password: this.credentialsData.newPassword,
password_repeat: this.passwordData.newPasswordRepeat password_repeat: this.credentialsData.newPasswordRepeat
} }
) )
this.user = data this.user = data
this.passwordData.show = false this.hasUsername = !!data.username
this.$q.notify({ this.credentialsData.show = false
Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Password updated.' message: 'Password updated.'
}) })
@@ -93,17 +145,34 @@ new Vue({
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
}, },
showChangePassword: function () { updatePubkey: async function () {
if (!this.user.username) { try {
const {data} = await LNbits.api.request(
'PUT',
'/api/v1/auth/pubkey',
null,
{
user_id: this.user.id,
pubkey: this.credentialsData.pubkey
}
)
this.user = data
this.hasUsername = !!data.username
this.credentialsData.show = false
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'positive',
message: 'Please set a username first.' message: 'Public key updated.'
}) })
return } catch (e) {
LNbits.utils.notifyApiError(e)
} }
this.passwordData = { },
showUpdateCredentials: function () {
this.credentialsData = {
show: true, show: true,
oldPassword: null, oldPassword: null,
username: this.user.username,
pubkey: this.user.pubkey,
newPassword: null, newPassword: null,
newPasswordRepeat: null newPasswordRepeat: null
} }
@@ -114,9 +183,12 @@ new Vue({
const {data} = await LNbits.api.getAuthenticatedUser() const {data} = await LNbits.api.getAuthenticatedUser()
this.user = data this.user = data
this.hasUsername = !!data.username this.hasUsername = !!data.username
if (!this.user.config) this.user.config = {} if (!this.user.extra) this.user.extra = {}
} catch (e) { } catch (e) {
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.applyGradient()
}
} }
}) })
+15 -4
View File
@@ -1,4 +1,4 @@
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
@@ -41,6 +41,7 @@ new Vue({
formAddExtensionsManifest: '', formAddExtensionsManifest: '',
formAllowedIPs: '', formAllowedIPs: '',
formBlockedIPs: '', formBlockedIPs: '',
nostrAcceptedUrl: '',
isSuperUser: false, isSuperUser: false,
wallet: {}, wallet: {},
cancel: {}, cancel: {},
@@ -181,11 +182,21 @@ new Vue({
b => b !== blocked_ip b => b !== blocked_ip
) )
}, },
addNostrUrl() {
const url = this.nostrAcceptedUrl.trim()
this.removeNostrUrl(url)
this.formData.nostr_absolute_request_urls.push(url)
this.nostrAcceptedUrl = ''
},
removeNostrUrl(url) {
this.formData.nostr_absolute_request_urls =
this.formData.nostr_absolute_request_urls.filter(b => b !== url)
},
restartServer() { restartServer() {
LNbits.api LNbits.api
.request('GET', '/admin/api/v1/restart/') .request('GET', '/admin/api/v1/restart/')
.then(response => { .then(response => {
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Success! Restarted Server', message: 'Success! Restarted Server',
icon: null icon: null
@@ -260,7 +271,7 @@ new Vue({
this.settings.lnbits_killswitch !== this.formData.lnbits_killswitch this.settings.lnbits_killswitch !== this.formData.lnbits_killswitch
this.settings = this.formData this.settings = this.formData
this.formData = _.clone(this.settings) this.formData = _.clone(this.settings)
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: `Success! Settings changed! ${ message: `Success! Settings changed! ${
this.needsRestart ? 'Restart required!' : '' this.needsRestart ? 'Restart required!' : ''
@@ -279,7 +290,7 @@ new Vue({
LNbits.api LNbits.api
.request('DELETE', '/admin/api/v1/settings') .request('DELETE', '/admin/api/v1/settings')
.then(response => { .then(response => {
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: message:
'Success! Restored settings to defaults, restart required!', 'Success! Restored settings to defaults, restart required!',
+164 -30
View File
@@ -1,15 +1,10 @@
/* globals crypto, moment, Vue, axios, Quasar, _ */
Vue.use(VueI18n)
window.LOCALE = 'en' window.LOCALE = 'en'
window.i18n = new VueI18n({ window.i18n = new VueI18n.createI18n({
locale: window.LOCALE, locale: window.LOCALE,
fallbackLocale: window.LOCALE, fallbackLocale: window.LOCALE,
messages: window.localisation messages: window.localisation
}) })
window.EventHub = new Vue()
window.LNbits = { window.LNbits = {
api: { api: {
request: function (method, url, apiKey, data) { request: function (method, url, apiKey, data) {
@@ -22,6 +17,9 @@ window.LNbits = {
data: data data: data
}) })
}, },
getServerHealth: function () {
return this.request('get', '/api/v1/health')
},
createInvoice: async function ( createInvoice: async function (
wallet, wallet,
amount, amount,
@@ -83,6 +81,17 @@ window.LNbits = {
} }
}) })
}, },
reset: function (reset_key, password, password_repeat) {
return axios({
method: 'PUT',
url: '/api/v1/auth/reset',
data: {
reset_key,
password,
password_repeat
}
})
},
login: function (username, password) { login: function (username, password) {
return axios({ return axios({
method: 'POST', method: 'POST',
@@ -90,6 +99,14 @@ window.LNbits = {
data: {username, password} data: {username, password}
}) })
}, },
loginByProvider: function (provider, headers, data) {
return axios({
method: 'POST',
url: `/api/v1/auth/${provider}`,
headers: headers,
data
})
},
loginUsr: function (usr) { loginUsr: function (usr) {
return axios({ return axios({
method: 'POST', method: 'POST',
@@ -145,10 +162,15 @@ window.LNbits = {
) )
}, },
updateBalance: function (credit, wallet_id) { updateBalance: function (credit, wallet_id) {
return LNbits.api.request('PUT', '/users/api/v1/topup', null, { return this.request('PUT', '/users/api/v1/topup', null, {
amount: credit, amount: credit,
id: wallet_id id: wallet_id
}) })
},
getCurrencies() {
return this.request('GET', '/api/v1/currencies').then(response => {
return ['sats', ...response.data]
})
} }
}, },
events: { events: {
@@ -187,7 +209,7 @@ window.LNbits = {
}, },
map: { map: {
extension: function (data) { extension: function (data) {
var obj = _.object( const obj = _.object(
[ [
'code', 'code',
'isValid', 'isValid',
@@ -204,7 +226,7 @@ window.LNbits = {
return obj return obj
}, },
user: function (data) { user: function (data) {
var obj = { const obj = {
id: data.id, id: data.id,
admin: data.admin, admin: data.admin,
email: data.email, email: data.email,
@@ -212,7 +234,7 @@ window.LNbits = {
wallets: data.wallets, wallets: data.wallets,
admin: data.admin admin: data.admin
} }
var mapWallet = this.wallet const mapWallet = this.wallet
obj.wallets = obj.wallets obj.wallets = obj.wallets
.map(function (obj) { .map(function (obj) {
return mapWallet(obj) return mapWallet(obj)
@@ -247,7 +269,7 @@ window.LNbits = {
payment: function (data) { payment: function (data) {
obj = { obj = {
checking_id: data.checking_id, checking_id: data.checking_id,
pending: data.pending, status: data.status,
amount: data.amount, amount: data.amount,
fee: data.fee, fee: data.fee,
memo: data.memo, memo: data.memo,
@@ -256,7 +278,7 @@ window.LNbits = {
preimage: data.preimage, preimage: data.preimage,
payment_hash: data.payment_hash, payment_hash: data.payment_hash,
expiry: data.expiry, expiry: data.expiry,
extra: data.extra, extra: data.extra ?? {},
wallet_id: data.wallet_id, wallet_id: data.wallet_id,
webhook: data.webhook, webhook: data.webhook,
webhook_status: data.webhook_status, webhook_status: data.webhook_status,
@@ -264,12 +286,12 @@ window.LNbits = {
fiat_currency: data.fiat_currency fiat_currency: data.fiat_currency
} }
obj.date = Quasar.utils.date.formatDate( obj.date = Quasar.date.formatDate(
new Date(obj.time * 1000), new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm' 'YYYY-MM-DD HH:mm'
) )
obj.dateFrom = moment(obj.date).fromNow() obj.dateFrom = moment(obj.date).fromNow()
obj.expirydate = Quasar.utils.date.formatDate( obj.expirydate = Quasar.date.formatDate(
new Date(obj.expiry * 1000), new Date(obj.expiry * 1000),
'YYYY-MM-DD HH:mm' 'YYYY-MM-DD HH:mm'
) )
@@ -280,14 +302,21 @@ window.LNbits = {
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat) obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.isIn = obj.amount > 0 obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0 obj.isOut = obj.amount < 0
obj.isPaid = !obj.pending obj.isPending = obj.status === 'pending'
obj.isPaid = obj.status === 'success'
obj.isFailed = obj.status === 'failed'
obj._q = [obj.memo, obj.sat].join(' ').toLowerCase() obj._q = [obj.memo, obj.sat].join(' ').toLowerCase()
try {
obj.details = JSON.parse(data.extra?.details || '{}')
} catch {
obj.details = {extraDetails: data.extra?.details}
}
return obj return obj
} }
}, },
utils: { utils: {
confirmDialog: function (msg) { confirmDialog: function (msg) {
return Quasar.plugins.Dialog.create({ return Quasar.Dialog.create({
message: msg, message: msg,
ok: { ok: {
flat: true, flat: true,
@@ -308,6 +337,12 @@ window.LNbits = {
.join('') .join('')
return hashHex return hashHex
}, },
formatDate: function (timestamp) {
return Quasar.date.formatDate(
new Date(timestamp * 1000),
'YYYY-MM-DD HH:mm'
)
},
formatCurrency: function (value, currency) { formatCurrency: function (value, currency) {
return new Intl.NumberFormat(window.LOCALE, { return new Intl.NumberFormat(window.LOCALE, {
style: 'currency', style: 'currency',
@@ -321,12 +356,15 @@ window.LNbits = {
return this.formatSat(value / 1000) return this.formatSat(value / 1000)
}, },
notifyApiError: function (error) { notifyApiError: function (error) {
var types = { if (!error.response) {
return console.error(error)
}
const types = {
400: 'warning', 400: 'warning',
401: 'warning', 401: 'warning',
500: 'negative' 500: 'negative'
} }
Quasar.plugins.Notify.create({ Quasar.Notify.create({
timeout: 5000, timeout: 5000,
type: types[error.response.status] || 'warning', type: types[error.response.status] || 'warning',
message: message:
@@ -340,9 +378,9 @@ window.LNbits = {
}, },
search: function (data, q, field, separator) { search: function (data, q, field, separator) {
try { try {
var queries = q.toLowerCase().split(separator || ' ') const queries = q.toLowerCase().split(separator || ' ')
return data.filter(function (obj) { return data.filter(function (obj) {
var matches = 0 let matches = 0
_.each(queries, function (q) { _.each(queries, function (q) {
if (obj[field].indexOf(q) !== -1) matches++ if (obj[field].indexOf(q) !== -1) matches++
}) })
@@ -371,8 +409,8 @@ window.LNbits = {
return new URLSearchParams(query) return new URLSearchParams(query)
}, },
exportCSV: function (columns, data, fileName) { exportCSV: function (columns, data, fileName) {
var wrapCsvValue = function (val, formatFn) { const wrapCsvValue = function (val, formatFn) {
var formatted = formatFn !== void 0 ? formatFn(val) : val let formatted = formatFn !== void 0 ? formatFn(val) : val
formatted = formatted =
formatted === void 0 || formatted === null ? '' : String(formatted) formatted === void 0 || formatted === null ? '' : String(formatted)
@@ -382,7 +420,7 @@ window.LNbits = {
return `"${formatted}"` return `"${formatted}"`
} }
var content = [ const content = [
columns.map(function (col) { columns.map(function (col) {
return wrapCsvValue(col.label) return wrapCsvValue(col.label)
}) })
@@ -403,14 +441,14 @@ window.LNbits = {
) )
.join('\r\n') .join('\r\n')
var status = Quasar.utils.exportFile( const status = Quasar.exportFile(
`${fileName || 'table-export'}.csv`, `${fileName || 'table-export'}.csv`,
content, content,
'text/csv' 'text/csv'
) )
if (status !== true) { if (status !== true) {
Quasar.plugins.Notify.create({ Quasar.Notify.create({
message: 'Browser denied file download...', message: 'Browser denied file download...',
color: 'negative', color: 'negative',
icon: null icon: null
@@ -422,6 +460,18 @@ window.LNbits = {
converter.setFlavor('github') converter.setFlavor('github')
converter.setOption('simpleLineBreaks', true) converter.setOption('simpleLineBreaks', true)
return converter.makeHtml(text) return converter.makeHtml(text)
},
hexToRgb: function (hex) {
return Quasar.colors.hexToRgb(hex)
},
hexDarken: function (hex, percent) {
return Quasar.colors.lighten(hex, percent)
},
hexAlpha: function (hex, alpha) {
return Quasar.colors.changeAlpha(hex, alpha)
},
getPaletteColor: function (color) {
return Quasar.colors.getPaletteColor(color)
} }
} }
} }
@@ -432,6 +482,8 @@ window.windowMixin = {
return { return {
toggleSubs: true, toggleSubs: true,
reactionChoice: 'confettiBothSides', reactionChoice: 'confettiBothSides',
gradientChoice:
this.$q.localStorage.getItem('lnbits.gradientBg') || false,
isUserAuthorized: false, isUserAuthorized: false,
g: { g: {
offline: !navigator.onLine, offline: !navigator.onLine,
@@ -451,10 +503,43 @@ window.windowMixin = {
document.body.setAttribute('data-theme', newValue) document.body.setAttribute('data-theme', newValue)
this.$q.localStorage.set('lnbits.theme', newValue) this.$q.localStorage.set('lnbits.theme', newValue)
}, },
applyGradient: function () {
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.setColors()
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
document.body.style.setProperty(
'background-image',
gradientStyle,
'important'
)
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important`
const style = document.createElement('style')
style.innerHTML =
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
`[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
document.head.appendChild(style)
}
},
setColors: function () {
this.$q.localStorage.set(
'lnbits.primaryColor',
LNbits.utils.getPaletteColor('primary')
)
this.$q.localStorage.set(
'lnbits.secondaryColor',
LNbits.utils.getPaletteColor('secondary')
)
this.$q.localStorage.set(
'lnbits.darkBgColor',
LNbits.utils.getPaletteColor('dark')
)
},
copyText: function (text, message, position) { copyText: function (text, message, position) {
var notify = this.$q.notify Quasar.copyToClipboard(text).then(function () {
Quasar.utils.copyToClipboard(text).then(function () { Quasar.Notify.create({
notify({
message: message || 'Copied to clipboard!', message: message || 'Copied to clipboard!',
position: position || 'bottom' position: position || 'bottom'
}) })
@@ -500,6 +585,52 @@ window.windowMixin = {
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
}) })
},
themeParams() {
const url = new URL(window.location.href)
const params = new URLSearchParams(window.location.search)
const fields = ['theme', 'dark', 'gradient']
const toBoolean = value =>
value.trim().toLowerCase() === 'true' || value === '1'
// Check if any of the relevant parameters ('theme', 'dark', 'gradient') are present in the URL.
if (fields.some(param => params.has(param))) {
const theme = params.get('theme')
const darkMode = params.get('dark')
const gradient = params.get('gradient')
if (
theme &&
this.g.allowedThemes.includes(theme.trim().toLowerCase())
) {
const normalizedTheme = theme.trim().toLowerCase()
document.body.setAttribute('data-theme', normalizedTheme)
this.$q.localStorage.set('lnbits.theme', normalizedTheme)
}
if (darkMode) {
const isDark = toBoolean(darkMode)
this.$q.localStorage.set('lnbits.darkMode', isDark)
if (!isDark) {
this.$q.localStorage.set('lnbits.gradientBg', false)
}
}
if (gradient) {
const isGradient = toBoolean(gradient)
this.$q.localStorage.set('lnbits.gradientBg', isGradient)
if (isGradient) {
this.$q.localStorage.set('lnbits.darkMode', true)
}
}
// Remove processed parameters
fields.forEach(param => params.delete(param))
window.history.replaceState(null, null, url.pathname)
}
this.setColors()
} }
}, },
created: async function () { created: async function () {
@@ -519,7 +650,7 @@ window.windowMixin = {
let locale = this.$q.localStorage.getItem('lnbits.lang') let locale = this.$q.localStorage.getItem('lnbits.lang')
if (locale) { if (locale) {
window.LOCALE = locale window.LOCALE = locale
window.i18n.locale = locale window.i18n.global.locale = locale
} }
this.g.langs = window.langs ?? [] this.g.langs = window.langs ?? []
@@ -552,6 +683,8 @@ window.windowMixin = {
) )
} }
this.applyGradient()
if (window.user) { if (window.user) {
this.g.user = Object.freeze(window.LNbits.map.user(window.user)) this.g.user = Object.freeze(window.LNbits.map.user(window.user))
} }
@@ -559,7 +692,7 @@ window.windowMixin = {
this.g.wallet = Object.freeze(window.LNbits.map.wallet(window.wallet)) this.g.wallet = Object.freeze(window.LNbits.map.wallet(window.wallet))
} }
if (window.extensions) { if (window.extensions) {
var user = this.g.user const user = this.g.user
const extensions = Object.freeze( const extensions = Object.freeze(
window.extensions window.extensions
.map(function (data) { .map(function (data) {
@@ -590,6 +723,7 @@ window.windowMixin = {
this.g.extensions = extensions this.g.extensions = extensions
} }
await this.checkUsrInUrl() await this.checkUsrInUrl()
this.themeParams()
} }
} }
+85 -329
View File
@@ -1,13 +1,19 @@
/* global _, Vue, moment, LNbits, EventHub, decryptLnurlPayAES */ window.app.component(QrcodeVue)
Vue.component('lnbits-fsat', { 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: { props: {
amount: { amount: {
type: Number, type: Number,
default: 0 default: 0
} }
}, },
template: '<span>{{ fsat }}</span>',
computed: { computed: {
fsat: function () { fsat: function () {
return LNbits.utils.formatSat(this.amount) return LNbits.utils.formatSat(this.amount)
@@ -15,66 +21,22 @@ Vue.component('lnbits-fsat', {
} }
}) })
Vue.component('lnbits-wallet-list', { window.app.component('lnbits-wallet-list', {
template: '#lnbits-wallet-list',
props: ['balance'],
data: function () { data: function () {
return { return {
user: null, user: null,
activeWallet: null, activeWallet: null,
activeBalance: [], balance: 0,
showForm: false, showForm: false,
walletName: '', walletName: '',
LNBITS_DENOMINATION: LNBITS_DENOMINATION LNBITS_DENOMINATION: LNBITS_DENOMINATION
} }
}, },
template: `
<q-list v-if="user && user.wallets.length" dense class="lnbits-drawer__q-list">
<q-item-label header v-text="$t('wallets')"></q-item-label>
<q-item v-for="wallet in wallets" :key="wallet.id"
clickable
:active="activeWallet && activeWallet.id === wallet.id"
tag="a" :href="wallet.url">
<q-item-section side>
<q-avatar size="md"
:color="(activeWallet && activeWallet.id === wallet.id)
? (($q.dark.isActive) ? 'primary' : 'primary')
: 'grey-5'">
<q-icon name="flash_on" :size="($q.dark.isActive) ? '21px' : '20px'"
:color="($q.dark.isActive) ? 'blue-grey-10' : 'grey-3'"></q-icon>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label lines="1">{{ wallet.name }}</q-item-label>
<q-item-label v-if="LNBITS_DENOMINATION != 'sats'" caption>{{ parseFloat(String(wallet.live_fsat).replaceAll(",", "")) / 100 }} {{ LNBITS_DENOMINATION }}</q-item-label>
<q-item-label v-else caption>{{ wallet.live_fsat }} {{ LNBITS_DENOMINATION }}</q-item-label>
</q-item-section>
<q-item-section side v-show="activeWallet && activeWallet.id === wallet.id">
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item clickable @click="showForm = !showForm">
<q-item-section side>
<q-icon :name="(showForm) ? 'remove' : 'add'" color="grey-5" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-caption" v-text="$t('add_wallet')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showForm">
<q-item-section>
<q-form @submit="createWallet">
<q-input filled dense v-model="walletName" label="Name wallet *">
<template v-slot:append>
<q-btn round dense flat icon="send" size="sm" @click="createWallet" :disable="walletName === ''"></q-btn>
</template>
</q-input>
</q-form>
</q-item-section>
</q-item>
</q-list>
`,
computed: { computed: {
wallets: function () { wallets: function () {
var bal = this.activeBalance var bal = this.balance
return this.user.wallets.map(function (obj) { return this.user.wallets.map(function (obj) {
obj.live_fsat = obj.live_fsat =
bal.length && bal[0] === obj.id bal.length && bal[0] === obj.id
@@ -87,9 +49,6 @@ Vue.component('lnbits-wallet-list', {
methods: { methods: {
createWallet: function () { createWallet: function () {
LNbits.api.createWallet(this.user.wallets[0], this.walletName) LNbits.api.createWallet(this.user.wallets[0], this.walletName)
},
updateWalletBalance: function (payload) {
this.activeBalance = payload
} }
}, },
created: function () { created: function () {
@@ -99,42 +58,18 @@ Vue.component('lnbits-wallet-list', {
if (window.wallet) { if (window.wallet) {
this.activeWallet = LNbits.map.wallet(window.wallet) this.activeWallet = LNbits.map.wallet(window.wallet)
} }
EventHub.$on('update-wallet-balance', this.updateWalletBalance) document.addEventListener('updateWalletBalance', this.updateWalletBalance)
} }
}) })
Vue.component('lnbits-extension-list', { window.app.component('lnbits-extension-list', {
template: '#lnbits-extension-list',
data: function () { data: function () {
return { return {
extensions: [], extensions: [],
user: null user: null
} }
}, },
template: `
<q-list v-if="user && userExtensions.length > 0" dense class="lnbits-drawer__q-list">
<q-item-label header v-text="$t('extensions')"></q-item-label>
<q-item v-for="extension in userExtensions" :key="extension.code"
clickable
:active="extension.isActive"
tag="a" :href="extension.url">
<q-item-section side>
<q-avatar size="md">
<q-img
:src="extension.tile"
style="max-width:20px"
></q-img>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label lines="1">{{ extension.name }} </q-item-label>
</q-item-section>
<q-item-section side v-show="extension.isActive">
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<div class="lt-md q-mt-xl q-mb-xl"></div>
</q-list>
`,
computed: { computed: {
userExtensions: function () { userExtensions: function () {
if (!this.user) return [] if (!this.user) return []
@@ -169,140 +104,36 @@ Vue.component('lnbits-extension-list', {
} }
}) })
Vue.component('lnbits-manage', { window.app.component('lnbits-manage', {
template: '#lnbits-manage',
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers'], props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers'],
methods: { methods: {
isActive: function (path) { isActive: function (path) {
return window.location.pathname === path return window.location.pathname === path
} }
}, },
data: function () { data() {
return { return {
extensions: [], extensions: [],
user: null user: null
} }
}, },
template: ` created() {
<q-list v-if="user" dense class="lnbits-drawer__q-list">
<q-item-label header v-text="$t('manage')"></q-item-label>
<div v-if="user.admin">
<q-item v-if='showAdmin' clickable tag="a" href="/admin" :active="isActive('/admin')">
<q-item-section side>
<q-icon name="admin_panel_settings" :color="isActive('/admin') ? 'primary' : 'grey-5'" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('server')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if='showNode' clickable tag="a" href="/node" :active="isActive('/node')">
<q-item-section side>
<q-icon name="developer_board" :color="isActive('/node') ? 'primary' : 'grey-5'" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('node')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showUsers" clickable tag="a" href="/users" :active="isActive('/users')">
<q-item-section side>
<q-icon name="groups" :color="isActive('/users') ? 'primary' : 'grey-5'" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('users')"></q-item-label>
</q-item-section>
</q-item>
</div>
<q-item v-if="showExtensions" clickable tag="a" href="/extensions" :active="isActive('/extensions')">
<q-item-section side>
<q-icon name="extension" :color="isActive('/extensions') ? 'primary' : 'grey-5'" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('extensions')"></q-item-label>
</q-item-section>
</q-item>
</q-list>
`,
created: function () {
if (window.user) { if (window.user) {
this.user = LNbits.map.user(window.user) this.user = LNbits.map.user(window.user)
} }
} }
}) })
Vue.component('lnbits-payment-details', { window.app.component('lnbits-payment-details', {
template: '#lnbits-payment-details',
props: ['payment'], props: ['payment'],
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
LNBITS_DENOMINATION: LNBITS_DENOMINATION LNBITS_DENOMINATION: LNBITS_DENOMINATION
} }
}, },
template: `
<div class="q-py-md" style="text-align: left">
<div v-if="payment.tag" class="row justify-center q-mb-md">
<q-badge v-if="hasTag" color="yellow" text-color="black">
#{{ payment.tag }}
</q-badge>
</div>
<div class="row">
<b v-text="$t('created')"></b>:
{{ payment.date }} ({{ payment.dateFrom }})
</div>
<div class="row" v-if="hasExpiry">
<b v-text="$t('expiry')"></b>:
{{ payment.expirydate }} ({{ payment.expirydateFrom }})
</div>
<div class="row">
<b v-text="$t('amount')"></b>:
{{ (payment.amount / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
</div>
<div class="row">
<b v-text="$t('fee')"></b>:
{{ (payment.fee / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
</div>
<div class="text-wrap">
<b style="white-space: nowrap;" v-text="$t('payment_hash')"></b>:&nbsp;{{ payment.payment_hash }}
<q-icon name="content_copy" @click="copyText(payment.payment_hash)" size="1em" color="grey" class="q-mb-xs cursor-pointer" />
</div>
<div class="text-wrap">
<b style="white-space: nowrap;" v-text="$t('memo')"></b>:&nbsp;{{ payment.memo }}
</div>
<div class="text-wrap" v-if="payment.webhook">
<b style="white-space: nowrap;" v-text="$t('webhook')"></b>:&nbsp;{{ payment.webhook }}:&nbsp;<q-badge :color="webhookStatusColor" text-color="white">
{{ webhookStatusText }}
</q-badge>
</div>
<div class="text-wrap" v-if="hasPreimage">
<b style="white-space: nowrap;" v-text="$t('payment_proof')"></b>:&nbsp;{{ payment.preimage }}
</div>
<div class="row" v-for="entry in extras">
<q-badge v-if="hasTag" color="secondary" text-color="white">
extra
</q-badge>
<b>{{ entry.key }}</b>:
{{ entry.value }}
</div>
<div class="row" v-if="hasSuccessAction">
<b>Success action</b>:
<lnbits-lnurlpay-success-action
:payment="payment"
:success_action="payment.extra.success_action"
></lnbits-lnurlpay-success-action>
</div>
</div>
`,
computed: { computed: {
hasPreimage() { hasPreimage() {
return ( return (
@@ -345,27 +176,16 @@ Vue.component('lnbits-payment-details', {
} }
}) })
Vue.component('lnbits-lnurlpay-success-action', { window.app.component('lnbits-lnurlpay-success-action', {
template: '#lnbits-lnurlpay-success-action',
props: ['payment', 'success_action'], props: ['payment', 'success_action'],
data() { data() {
return { return {
decryptedValue: this.success_action.ciphertext decryptedValue: this.success_action.ciphertext
} }
}, },
template: `
<div>
<p class="q-mb-sm">{{ success_action.message || success_action.description }}</p>
<code v-if="decryptedValue" class="text-h6 q-mt-sm q-mb-none">
{{ decryptedValue }}
</code>
<p v-else-if="success_action.url" class="text-h6 q-mt-sm q-mb-none">
<a target="_blank" style="color: inherit;" :href="success_action.url">{{ success_action.url }}</a>
</p>
</div>
`,
mounted: function () { mounted: function () {
if (this.success_action.tag !== 'aes') return null if (this.success_action.tag !== 'aes') return null
decryptLnurlPayAES(this.success_action, this.payment.preimage).then( decryptLnurlPayAES(this.success_action, this.payment.preimage).then(
value => { value => {
this.decryptedValue = value this.decryptedValue = value
@@ -374,26 +194,23 @@ Vue.component('lnbits-lnurlpay-success-action', {
} }
}) })
Vue.component('lnbits-qrcode', { window.app.component('lnbits-qrcode', {
mixins: [windowMixin], template: '#lnbits-qrcode',
mixins: [window.windowMixin],
components: {
QrcodeVue
},
props: ['value'], props: ['value'],
components: {[VueQrcode.name]: VueQrcode},
data() { data() {
return { return {
logo: LNBITS_QR_LOGO logo: LNBITS_QR_LOGO
} }
}, }
template: `
<div class="qrcode__wrapper">
<qrcode :value="value"
:options="{errorCorrectionLevel: 'Q', width: 800}" class="rounded-borders"></qrcode>
<img class="qrcode__image" :src="logo" alt="..." />
</div>
`
}) })
Vue.component('lnbits-notifications-btn', { window.app.component('lnbits-notifications-btn', {
mixins: [windowMixin], template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
props: ['pubkey'], props: ['pubkey'],
data() { data() {
return { return {
@@ -403,26 +220,6 @@ Vue.component('lnbits-notifications-btn', {
isPermissionDenied: false isPermissionDenied: false
} }
}, },
template: `
<q-btn
v-if="g.user.wallets"
:disabled="!this.isSupported"
dense
flat
round
@click="toggleNotifications()"
:icon="this.isSubscribed ? 'notifications_active' : 'notifications_off'"
size="sm"
type="a"
>
<q-tooltip v-if="this.isSupported && !this.isSubscribed">Subscribe to notifications</q-tooltip>
<q-tooltip v-if="this.isSupported && this.isSubscribed">Unsubscribe from notifications</q-tooltip>
<q-tooltip v-if="this.isSupported && this.isPermissionDenied">
Notifications are disabled,<br/>please enable or reset permissions
</q-tooltip>
<q-tooltip v-if="!this.isSupported">Notifications are not supported</q-tooltip>
</q-btn>
`,
methods: { methods: {
// converts base64 to Array buffer // converts base64 to Array buffer
urlB64ToUint8Array(base64String) { urlB64ToUint8Array(base64String) {
@@ -605,63 +402,20 @@ Vue.component('lnbits-notifications-btn', {
} }
}) })
Vue.component('lnbits-dynamic-fields', { window.app.component('lnbits-dynamic-fields', {
mixins: [windowMixin], template: '#lnbits-dynamic-fields',
props: ['options', 'value'], mixins: [window.windowMixin],
props: ['options', 'modelValue'],
data() { data() {
return { return {
formData: null formData: null,
rules: [val => !!val || 'Field is required']
} }
}, },
template: `
<div v-if="formData">
<div class="row q-mb-lg" v-for="o in options">
<div class="col auto-width">
<p v-if=o.options?.length class="q-ml-xl">
<span v-text="o.name"></span> <small v-if="o.description"> (<span v-text="o.description"></span>)</small>
</p>
<lnbits-dynamic-fields v-if="o.options?.length" :options="o.options" v-model="formData[o.name]"
@input="handleValueChanged" class="q-ml-xl">
</lnbits-dynamic-fields>
<div v-else>
<q-input v-if="o.type === 'number'" v-model="formData[o.name]" @input="handleValueChanged" type="number"
:label="o.name" :hint="o.description" filled dense>
</q-input>
<q-input v-else-if="o.type === 'text'" v-model="formData[o.name]" @input="handleValueChanged" type="textarea"
rows="5" :label="o.name" :hint="o.description" filled dense>
</q-input>
<q-input v-else-if="o.type === 'password'" v-model="formData[o.name]" @input="handleValueChanged" type="password"
:label="o.name" :hint="o.description" filled dense>
</q-input>
<div v-else-if="o.type === 'bool'">
<q-item tag="label" v-ripple>
<q-item-section avatar top>
<q-checkbox v-model="formData[o.name]" @input="handleValueChanged" />
</q-item-section>
<q-item-section>
<q-item-label><span v-text="o.name"></span></q-item-label>
<q-item-label caption> <span v-text="o.description"></span> </q-item-label>
</q-item-section>
</q-item>
</div>
<q-select v-else-if="o.type === 'select'" v-model="formData[o.name]" @input="handleValueChanged" :label="o.name"
:hint="o.description" :options="o.values"></q-select>
<q-select v-else-if="o.isList" filled multiple dense v-model.trim="formData[o.name]" use-input use-chips
@input="handleValueChanged" multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
:label="o.name" :hint="o.description">
</q-select>
<q-input v-else v-model="formData[o.name]" @input="handleValueChanged" :label="o.name" :hint="o.description"
filled dense>
</q-input>
</div>
</div>
</div>
</div>
`,
methods: { methods: {
applyRules(required) {
return required ? this.rules : []
},
buildData(options, data = {}) { buildData(options, data = {}) {
return options.reduce((d, option) => { return options.reduce((d, option) => {
if (option.options?.length) { if (option.options?.length) {
@@ -673,23 +427,55 @@ Vue.component('lnbits-dynamic-fields', {
}, {}) }, {})
}, },
handleValueChanged() { handleValueChanged() {
this.$emit('input', this.formData) this.$emit('update:model-value', this.formData)
} }
}, },
created: function () { created() {
this.formData = this.buildData(this.options, this.value) this.formData = this.buildData(this.options, this.modelValue)
} }
}) })
Vue.component('lnbits-update-balance', { window.app.component('lnbits-dynamic-chips', {
mixins: [windowMixin], template: '#lnbits-dynamic-chips',
mixins: [window.windowMixin],
props: ['modelValue'],
data() {
return {
chip: '',
chips: []
}
},
methods: {
addChip() {
if (!this.chip) return
this.chips.push(this.chip)
this.chip = ''
this.$emit('update:model-value', this.chips.join(','))
},
removeChip(index) {
this.chips.splice(index, 1)
this.$emit('update:model-value', this.chips.join(','))
}
},
created() {
if (typeof this.modelValue === 'string') {
this.chips = this.modelValue.split(',')
} else {
this.chips = [...this.modelValue]
}
}
})
window.app.component('lnbits-update-balance', {
template: '#lnbits-update-balance',
mixins: [window.windowMixin],
props: ['wallet_id', 'callback'], props: ['wallet_id', 'callback'],
computed: { computed: {
denomination() { denomination() {
return LNBITS_DENOMINATION return LNBITS_DENOMINATION
}, },
admin() { admin() {
return this.g.user.admin return user.super_user
} }
}, },
data: function () { data: function () {
@@ -713,7 +499,7 @@ Vue.component('lnbits-update-balance', {
}) })
.then(_ => { .then(_ => {
credit = parseInt(credit) credit = parseInt(credit)
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: this.$t('wallet_topup_ok', { message: this.$t('wallet_topup_ok', {
amount: credit amount: credit
@@ -726,35 +512,5 @@ Vue.component('lnbits-update-balance', {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
} }
}, }
template: `
<q-btn
v-if="admin"
round
color="primary"
icon="add"
size="sm"
>
<q-popup-edit
class="bg-accent text-white"
v-slot="scope"
v-model="credit"
>
<q-input
filled
:label='$t("credit_label", { denomination: denomination })'
:hint="$t('credit_hint')"
v-model="scope.value"
dense
autofocus
@keyup.enter="updateBalance(scope.value)"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
<q-tooltip>Topup Wallet</q-tooltip>
</q-btn>
`
}) })
@@ -1,10 +1,11 @@
Vue.component('lnbits-extension-settings-form', { window.app.component('lnbits-extension-settings-form', {
name: 'lnbits-extension-settings-form', name: 'lnbits-extension-settings-form',
template: '#lnbits-extension-settings-form',
props: ['options', 'adminkey', 'endpoint'], props: ['options', 'adminkey', 'endpoint'],
methods: { methods: {
updateSettings: async function () { async updateSettings() {
if (!this.settings) { if (!this.settings) {
return Quasar.plugins.Notify.create({ return Quasar.Notify.create({
message: 'No settings to update', message: 'No settings to update',
type: 'negative' type: 'negative'
}) })
@@ -49,16 +50,6 @@ Vue.component('lnbits-extension-settings-form', {
created: async function () { created: async function () {
await this.getSettings() await this.getSettings()
}, },
template: `
<q-form v-if="settings" @submit="updateSettings" class="q-gutter-md">
<lnbits-dynamic-fields :options="options" v-model="settings"></lnbits-dynamic-fields>
<div class="row q-mt-lg">
<q-btn v-close-popup unelevated color="primary" type="submit">Update</q-btn>
<q-btn v-close-popup unelevated color="danger" @click="resetSettings" >Reset</q-btn>
<slot name="actions"></slot>
</div>
</q-form>
`,
data: function () { data: function () {
return { return {
settings: undefined settings: undefined
@@ -66,22 +57,10 @@ Vue.component('lnbits-extension-settings-form', {
} }
}) })
Vue.component('lnbits-extension-settings-btn-dialog', { window.app.component('lnbits-extension-settings-btn-dialog', {
template: '#lnbits-extension-settings-btn-dialog',
name: 'lnbits-extension-settings-btn-dialog', name: 'lnbits-extension-settings-btn-dialog',
props: ['options', 'adminkey', 'endpoint'], props: ['options', 'adminkey', 'endpoint'],
template: `
<q-btn v-if="options" unelevated @click="show = true" color="primary" icon="settings" class="float-right">
<q-dialog v-model="show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<lnbits-extension-settings-form :options="options" :adminkey="adminkey" :endpoint="endpoint">
<template v-slot:actions>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</template>
</lnbits-extension-settings-form>
</q-card>
</q-dialog>
</q-btn>
`,
data: function () { data: function () {
return { return {
show: false show: false
@@ -1,6 +1,15 @@
Vue.component('lnbits-funding-sources', { window.app.component('lnbits-funding-sources', {
mixins: [windowMixin], template: '#lnbits-funding-sources',
mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'], props: ['form-data', 'allowed-funding-sources'],
methods: {
getFundingSourceLabel(item) {
const fundingSource = this.rawFundingSources.find(
fundingSource => fundingSource[0] === item
)
return fundingSource ? fundingSource[1] : item
}
},
computed: { computed: {
fundingSources() { fundingSources() {
let tmp = [] let tmp = []
@@ -14,6 +23,9 @@ Vue.component('lnbits-funding-sources', {
tmp.push([key, tmpObj]) tmp.push([key, tmpObj])
} }
return new Map(tmp) return new Map(tmp)
},
sortedAllowedFundingSources() {
return this.allowedFundingSources.sort()
} }
}, },
data() { data() {
@@ -93,12 +105,21 @@ Vue.component('lnbits-funding-sources', {
], ],
[ [
'LNbitsWallet', 'LNbitsWallet',
'LNBits', 'LNbits',
{ {
lnbits_endpoint: 'Endpoint', lnbits_endpoint: 'Endpoint',
lnbits_key: 'Admin Key' lnbits_key: 'Admin Key'
} }
], ],
[
'BlinkWallet',
'Blink',
{
blink_api_endpoint: 'Endpoint',
blink_ws_endpoint: 'WebSocket',
blink_token: 'Key'
}
],
[ [
'AlbyWallet', 'AlbyWallet',
'Alby', 'Alby',
@@ -107,6 +128,16 @@ Vue.component('lnbits-funding-sources', {
alby_access_token: 'Key' alby_access_token: 'Key'
} }
], ],
[
'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'
}
],
[ [
'ZBDWallet', 'ZBDWallet',
'ZBD', 'ZBD',
@@ -145,47 +176,26 @@ Vue.component('lnbits-funding-sources', {
spark_url: 'Endpoint', spark_url: 'Endpoint',
spark_token: 'Token' spark_token: 'Token'
} }
],
[
'NWCWallet',
'Nostr Wallet Connect',
{
nwc_pairing_url: 'Pairing URL'
}
],
[
'BreezSdkWallet',
'Breez SDK',
{
breez_api_key: 'Breez API Key',
breez_greenlight_seed: 'Greenlight Seed',
breez_greenlight_device_key: 'Greenlight Device Key',
breez_greenlight_device_cert: 'Greenlight Device Cert',
breez_greenlight_invite_code: 'Greenlight Invite Code'
}
] ]
] ]
} }
}, }
template: `
<div class="funding-sources">
<h6 class="q-mt-xl q-mb-md">Funding Sources</h6>
<div class="row">
<div class="col-12">
<p>Active Funding<small> (Requires server restart)</small></p>
<q-select
filled
v-model="formData.lnbits_backend_wallet_class"
hint="Select the active funding wallet"
:options="allowedFundingSources"
></q-select>
</div>
</div>
<q-list
class="q-mt-md"
v-for="(fund, idx) in allowedFundingSources"
:key="idx"
>
<div v-if="fundingSources.get(fund) && fund === formData.lnbits_backend_wallet_class">
<div class="row"
v-for="([key, prop], i) in Object.entries(fundingSources.get(fund))"
:key="i"
>
<div class="col-12">
<q-input
filled
type="text"
class="q-mt-sm"
v-model="formData[key]"
:label="prop.label"
:hint="prop.hint"
></q-input>
</div>
</div>
</div>
</q-list>
</div>
`
}) })
+6 -30
View File
@@ -35,14 +35,14 @@ function generateChart(canvas, rawData) {
type: 'bar', type: 'bar',
label: 'in', label: 'in',
barPercentage: 0.75, barPercentage: 0.75,
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green backgroundColor: 'rgba(76, 175, 80, 0.5)' // green
}, },
{ {
data: data.spending, data: data.spending,
type: 'bar', type: 'bar',
label: 'out', label: 'out',
barPercentage: 0.75, barPercentage: 0.75,
backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink backgroundColor: 'rgba(233, 30, 99, 0.5)' // pink
} }
] ]
}, },
@@ -80,10 +80,11 @@ function generateChart(canvas, rawData) {
}) })
} }
Vue.component('payment-chart', { window.app.component('payment-chart', {
template: '#payment-chart',
name: 'payment-chart', name: 'payment-chart',
props: ['wallet'], props: ['wallet'],
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
paymentsChart: { paymentsChart: {
@@ -128,30 +129,5 @@ Vue.component('payment-chart', {
this.paymentsChart.show = false this.paymentsChart.show = false
}) })
} }
}, }
template: `
<span id="payment-chart">
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart" >
<q-tooltip>
<span v-text="$t('chart_tooltip')"></span>
</q-tooltip>
</q-btn>
<q-dialog v-model="paymentsChart.show" position="top">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<div class="row q-gutter-sm justify-between">
<div class="text-h6">Payments Chart</div>
<q-select label="Group" filled dense v-model="paymentsChart.group"
style="min-width: 120px"
:options="paymentsChart.groupOptions"
>
</q-select>
</div>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
</span>
`
}) })
+46 -212
View File
@@ -1,7 +1,8 @@
Vue.component('payment-list', { window.app.component('payment-list', {
name: 'payment-list', name: 'payment-list',
template: '#payment-list',
props: ['update', 'wallet', 'mobileSimple', 'lazy'], props: ['update', 'wallet', 'mobileSimple', 'lazy'],
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
denomination: LNBITS_DENOMINATION, denomination: LNBITS_DENOMINATION,
@@ -33,6 +34,8 @@ Vue.component('payment-list', {
search: null, search: null,
loading: false loading: false
}, },
exportTagName: '',
exportPaymentTagList: [],
paymentsCSV: { paymentsCSV: {
columns: [ columns: [
{ {
@@ -146,7 +149,7 @@ Vue.component('payment-list', {
paymentTableRowKey: function (row) { paymentTableRowKey: function (row) {
return row.payment_hash + row.amount return row.payment_hash + row.amount
}, },
exportCSV: function () { exportCSV(detailed = false) {
// status is important for export but it is not in paymentsTable // status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons // because it is manually added with payment detail link and icons
// and would cause duplication in the list // and would cause duplication in the list
@@ -157,14 +160,51 @@ Vue.component('payment-list', {
} }
const params = new URLSearchParams(query) const params = new URLSearchParams(query)
LNbits.api.getPayments(this.wallet, params).then(response => { LNbits.api.getPayments(this.wallet, params).then(response => {
const payments = response.data.data.map(LNbits.map.payment) let payments = response.data.data.map(LNbits.map.payment)
let columns = this.paymentsCSV.columns
if (detailed) {
if (this.exportPaymentTagList.length) {
payments = payments.filter(p =>
this.exportPaymentTagList.includes(p.tag)
)
}
const extraColumns = Object.keys(
payments.reduce((e, p) => ({...e, ...p.details}), {})
).map(col => ({
name: col,
align: 'right',
label:
col.charAt(0).toUpperCase() +
col.slice(1).replace(/([A-Z])/g, ' $1'),
field: row => row.details[col],
format: data =>
typeof data === 'object' ? JSON.stringify(data) : data
}))
columns = this.paymentsCSV.columns.concat(extraColumns)
}
LNbits.utils.exportCSV( LNbits.utils.exportCSV(
this.paymentsCSV.columns, columns,
payments, payments,
this.wallet.name + '-payments' this.wallet.name + '-payments'
) )
}) })
}, },
addFilterTag: function () {
if (!this.exportTagName) return
const value = this.exportTagName.trim()
this.exportPaymentTagList = this.exportPaymentTagList.filter(
v => v !== value
)
this.exportPaymentTagList.push(value)
this.exportTagName = ''
},
removeExportTag: function (value) {
this.exportPaymentTagList = this.exportPaymentTagList.filter(
v => v !== value
)
},
formatCurrency: function (amount, currency) { formatCurrency: function (amount, currency) {
try { try {
return LNbits.utils.formatCurrency(amount, currency) return LNbits.utils.formatCurrency(amount, currency)
@@ -184,211 +224,5 @@ Vue.component('payment-list', {
}, },
created: function () { created: function () {
if (this.lazy === undefined) this.fetchPayments() if (this.lazy === undefined) this.fetchPayments()
}, }
template: `
<q-card
:style="$q.screen.lt.md ? {
background: $q.screen.lt.md ? 'none !important': ''
, boxShadow: $q.screen.lt.md ? 'none !important': ''
, marginTop: $q.screen.lt.md ? '0px !important': ''
} : ''"
>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5
class="text-subtitle1 q-my-none"
:v-text="$t('transactions')"
></h5>
</div>
<div class="gt-sm col-auto">
<q-btn flat color="grey" @click="exportCSV" :label="$t('export_csv')" ></q-btn>
<payment-chart :wallet="wallet" />
</div>
</div>
<q-input
:style="$q.screen.lt.md ? {
display: mobileSimple ? 'none !important': ''
} : ''"
filled
dense
clearable
v-model="paymentsTable.search"
debounce="300"
:placeholder="$t('search_by_tag_memo_amount')"
class="q-mb-md"
>
</q-input>
<q-table
dense
flat
:data="paymentsOmitter"
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
:no-data-label="$t('no_transactions')"
:filter="paymentsTable.search"
:loading="paymentsTable.loading"
:hide-header="mobileSimple"
:hide-bottom="mobileSimple"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.isPaid"
size="14px"
:name="props.row.isOut ? 'call_made' : 'call_received'"
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else
name="settings_ethernet"
color="grey"
@click="props.expand = !props.expand"
>
<q-tooltip
><span v-text="$t('pending')"></span
></q-tooltip>
</q-icon>
</q-td>
<q-td
key="time"
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-badge
v-if="props.row.tag"
color="yellow"
text-color="black"
>
<a
v-text="'#'+props.row.tag"
class="inherit"
:href="['/', props.row.tag].join('')"
></a>
</q-badge>
<span v-text="props.row.memo"></span>
<br />
<i>
<span v-text="props.row.dateFrom"></span>
<q-tooltip
><span v-text="props.row.date"></span
></q-tooltip>
</i>
</q-td>
<q-td
auto-width
key="amount"
v-if="denomination != 'sats'"
:props="props"
class="col1"
v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
>
</q-td>
<q-td class="col2" auto-width key="amount" v-else :props="props">
<span v-text="props.row.fsat"></span>
<br />
<i v-if="props.row.extra.wallet_fiat_currency">
<span
v-text="formatCurrency(props.row.extra.wallet_fiat_amount, props.row.extra.wallet_fiat_currency)"
></span>
<br />
</i>
<i v-if="props.row.extra.fiat_currency">
<span
v-text="formatCurrency(props.row.extra.fiat_amount, props.row.extra.fiat_currency)"
></span>
</i>
</q-td>
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div
v-if="props.row.bolt11"
class="text-center q-mb-lg"
>
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
:label="$t('copy_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
<span v-text="$t('payment_received')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
<span v-text="$t('payment_sent')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
`
}) })
+96 -4
View File
@@ -1,6 +1,6 @@
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
disclaimerDialog: { disclaimerDialog: {
@@ -13,6 +13,7 @@ new Vue({
authMethod: 'username-password', authMethod: 'username-password',
usr: '', usr: '',
username: '', username: '',
reset_key: '',
email: '', email: '',
password: '', password: '',
passwordRepeat: '', passwordRepeat: '',
@@ -42,6 +43,79 @@ new Vue({
this.authAction = 'register' this.authAction = 'register'
this.authMethod = authMethod this.authMethod = authMethod
}, },
signInWithNostr: async function () {
try {
const nostrToken = await this.createNostrToken()
if (!nostrToken) {
return
}
resp = await LNbits.api.loginByProvider(
'nostr',
{Authorization: nostrToken},
{}
)
window.location.href = '/wallet'
} catch (error) {
console.warn(error)
const details = error?.response?.data?.detail || `${error}`
Quasar.Notify.create({
type: 'negative',
message: 'Failed to sign in with Nostr.',
caption: details
})
}
},
createNostrToken: async function () {
try {
async function _signEvent(e) {
try {
const {data} = await LNbits.api.getServerHealth()
e.created_at = data.server_time
return await window.nostr.signEvent(e)
} catch (error) {
console.error(error)
Quasar.Notify.create({
type: 'negative',
message: 'Failed to sign nostr event.',
caption: `${error}`
})
}
}
if (!window.nostr?.signEvent) {
Quasar.Notify.create({
type: 'negative',
message: 'No Nostr signing app detected.',
caption: 'Is "window.nostr" present?'
})
return
}
const tagU = `${window.location}nostr`
const tagMethod = 'POST'
const nostrToken = await NostrTools.nip98.getToken(
tagU,
tagMethod,
e => _signEvent(e),
true
)
const isTokenValid = await NostrTools.nip98.validateToken(
nostrToken,
tagU,
tagMethod
)
if (!isTokenValid) {
throw new Error('Invalid signed token!')
}
return nostrToken
} catch (error) {
console.warn(error)
Quasar.Notify.create({
type: 'negative',
message: 'Failed create Nostr event.',
caption: `${error}`
})
}
},
register: async function () { register: async function () {
try { try {
await LNbits.api.register( await LNbits.api.register(
@@ -55,6 +129,18 @@ new Vue({
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
}, },
reset: async function () {
try {
await LNbits.api.reset(
this.reset_key,
this.password,
this.passwordRepeat
)
window.location.href = '/wallet'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
login: async function () { login: async function () {
try { try {
await LNbits.api.login(this.username, this.password) await LNbits.api.login(this.username, this.password)
@@ -69,6 +155,7 @@ new Vue({
this.usr = '' this.usr = ''
window.location.href = '/wallet' window.location.href = '/wallet'
} catch (e) { } catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
}, },
@@ -78,7 +165,7 @@ new Vue({
}) })
}, },
processing: function () { processing: function () {
this.$q.notify({ Quasar.Notify.create({
timeout: 0, timeout: 0,
message: 'Processing...', message: 'Processing...',
icon: null icon: null
@@ -93,10 +180,15 @@ new Vue({
}, },
created() { created() {
this.description = SITE_DESCRIPTION this.description = SITE_DESCRIPTION
this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized') this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized')
if (this.isUserAuthorized) { if (this.isUserAuthorized) {
window.location.href = '/wallet' window.location.href = '/wallet'
} }
this.reset_key = new URLSearchParams(window.location.search).get(
'reset_key'
)
if (this.reset_key) {
this.authAction = 'reset'
}
} }
}) })
+4
View File
@@ -0,0 +1,4 @@
window.app.use(VueQrcodeReader)
window.app.use(Quasar)
window.app.use(window.i18n)
window.app.mount('#vue')
+12 -12
View File
@@ -4,7 +4,7 @@ function shortenNodeId(nodeId) {
: '...' : '...'
} }
Vue.component('lnbits-node-ranks', { window.app.component('lnbits-node-ranks', {
props: ['ranks'], props: ['ranks'],
data: function () { data: function () {
return { return {
@@ -35,7 +35,7 @@ Vue.component('lnbits-node-ranks', {
` `
}) })
Vue.component('lnbits-channel-stats', { window.app.component('lnbits-channel-stats', {
props: ['stats'], props: ['stats'],
data: function () { data: function () {
return { return {
@@ -71,7 +71,7 @@ Vue.component('lnbits-channel-stats', {
} }
}) })
Vue.component('lnbits-stat', { window.app.component('lnbits-stat', {
props: ['title', 'amount', 'msat', 'btc'], props: ['title', 'amount', 'msat', 'btc'],
computed: { computed: {
value: function () { value: function () {
@@ -99,20 +99,20 @@ Vue.component('lnbits-stat', {
` `
}) })
Vue.component('lnbits-node-qrcode', { window.app.component('lnbits-node-qrcode', {
props: ['info'], props: ['info'],
mixins: [windowMixin], mixins: [window.windowMixin],
template: ` template: `
<q-card class="my-card"> <q-card class="my-card">
<q-card-section> <q-card-section>
<div class="text-h6"> <div class="text-h6">
<div style="text-align: center"> <div style="text-align: center">
<qrcode <vue-qrcode
:value="info.addresses[0]" :value="info.addresses[0]"
:options="{width: 250}" :options="{width: 250}"
v-if='info.addresses[0]' v-if='info.addresses[0]'
class="rounded-borders" class="rounded-borders"
></qrcode> ></vue-qrcode>
<div v-else class='text-subtitle1'> <div v-else class='text-subtitle1'>
No addresses available No addresses available
</div> </div>
@@ -132,14 +132,14 @@ Vue.component('lnbits-node-qrcode', {
` `
}) })
Vue.component('lnbits-node-info', { window.app.component('lnbits-node-info', {
props: ['info'], props: ['info'],
data() { data() {
return { return {
showDialog: false showDialog: false
} }
}, },
mixins: [windowMixin], mixins: [window.windowMixin],
methods: { methods: {
shortenNodeId shortenNodeId
}, },
@@ -177,7 +177,7 @@ Vue.component('lnbits-node-info', {
` `
}) })
Vue.component('lnbits-stat', { window.app.component('lnbits-stat', {
props: ['title', 'amount', 'msat', 'btc'], props: ['title', 'amount', 'msat', 'btc'],
computed: { computed: {
value: function () { value: function () {
@@ -205,7 +205,7 @@ Vue.component('lnbits-stat', {
` `
}) })
Vue.component('lnbits-channel-balance', { window.app.component('lnbits-channel-balance', {
props: ['balance', 'color'], props: ['balance', 'color'],
methods: { methods: {
formatMsat: function (msat) { formatMsat: function (msat) {
@@ -246,7 +246,7 @@ Vue.component('lnbits-channel-balance', {
` `
}) })
Vue.component('lnbits-date', { window.app.component('lnbits-date', {
props: ['ts'], props: ['ts'],
computed: { computed: {
date: function () { date: function () {
+64 -13
View File
@@ -1,6 +1,6 @@
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
isSuperUser: false, isSuperUser: false,
@@ -164,6 +164,31 @@ new Vue({
this.chart1 = new Chart(this.$refs.chart1.getContext('2d'), { this.chart1 = new Chart(this.$refs.chart1.getContext('2d'), {
type: 'bubble', type: 'bubble',
options: { options: {
scales: {
x: {
type: 'linear',
beginAtZero: true,
title: {
text: 'Transaction count'
}
},
y: {
type: 'linear',
beginAtZero: true,
title: {
text: 'User balance in million sats'
}
}
},
tooltip: {
callbacks: {
label: function (tooltipItem, data) {
const dataset = data.datasets[tooltipItem.datasetIndex]
const dataPoint = dataset.data[tooltipItem.index]
return dataPoint.customLabel || ''
}
}
},
layout: { layout: {
padding: 10 padding: 10
} }
@@ -171,7 +196,7 @@ new Vue({
data: { data: {
datasets: [ datasets: [
{ {
label: 'Balance - TX Count in million sats', label: 'Wallet balance vs transaction count',
backgroundColor: 'rgb(255, 99, 132)', backgroundColor: 'rgb(255, 99, 132)',
data: [] data: []
} }
@@ -180,18 +205,34 @@ new Vue({
}) })
}, },
methods: { methods: {
formatDate: function (value) {
return LNbits.utils.formatDate(value)
},
formatSat: function (value) { formatSat: function (value) {
return LNbits.utils.formatSat(Math.floor(value / 1000)) return LNbits.utils.formatSat(Math.floor(value / 1000))
}, },
usersTableRowKey: function (row) { resetPassword(user_id) {
return row.id return LNbits.api
.request('PUT', `/users/api/v1/user/${user_id}/reset_password`)
.then(res => {
this.$q.notify({
type: 'positive',
message: 'generated key for password reset',
icon: null
})
const url = window.location.origin + '?reset_key=' + res.data
this.copyText(url)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}, },
createUser() { createUser() {
LNbits.api LNbits.api
.request('POST', '/users/api/v1/user', null, this.createUserDialog.data) .request('POST', '/users/api/v1/user', null, this.createUserDialog.data)
.then(() => { .then(() => {
this.fetchUsers() this.fetchUsers()
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Success! User created!', message: 'Success! User created!',
icon: null icon: null
@@ -211,7 +252,7 @@ new Vue({
) )
.then(() => { .then(() => {
this.fetchUsers() this.fetchUsers()
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Success! User created!', message: 'Success! User created!',
icon: null icon: null
@@ -229,7 +270,7 @@ new Vue({
.request('DELETE', `/users/api/v1/user/${user_id}`) .request('DELETE', `/users/api/v1/user/${user_id}`)
.then(() => { .then(() => {
this.fetchUsers() this.fetchUsers()
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Success! User deleted!', message: 'Success! User deleted!',
icon: null icon: null
@@ -248,7 +289,7 @@ new Vue({
) )
.then(() => { .then(() => {
this.fetchWallets(user_id) this.fetchWallets(user_id)
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Success! Undeleted user wallet!', message: 'Success! Undeleted user wallet!',
icon: null icon: null
@@ -267,7 +308,7 @@ new Vue({
.request('DELETE', `/users/api/v1/user/${user_id}/wallet/${wallet}`) .request('DELETE', `/users/api/v1/user/${user_id}/wallet/${wallet}`)
.then(() => { .then(() => {
this.fetchWallets(user_id) this.fetchWallets(user_id)
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Success! User wallet deleted!', message: 'Success! User wallet deleted!',
icon: null icon: null
@@ -291,10 +332,20 @@ new Vue({
}) })
const data = filtered.map(user => { const data = filtered.map(user => {
const labelUsername = `${user.username ? 'User: ' + user.username + '. ' : ''}`
const userBalanceSats = Math.floor(
user.balance_msat / 1000
).toLocaleString()
return { return {
x: user.transaction_count, x: user.transaction_count,
y: user.balance_msat / 1000000000, y: user.balance_msat / 1000000000,
r: 3 r: 4,
customLabel:
labelUsername +
'Balance: ' +
userBalanceSats +
' sats. Tx count: ' +
user.transaction_count
} }
}) })
this.chart1.data.datasets[0].data = data this.chart1.data.datasets[0].data = data
@@ -337,7 +388,7 @@ new Vue({
.request('GET', `/users/api/v1/user/${user_id}/admin`) .request('GET', `/users/api/v1/user/${user_id}/admin`)
.then(() => { .then(() => {
this.fetchUsers() this.fetchUsers()
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Success! Toggled admin!', message: 'Success! Toggled admin!',
icon: null icon: null
@@ -369,7 +420,7 @@ new Vue({
this.wallet this.wallet
) )
.then(_ => { .then(_ => {
this.$q.notify({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: `Success! Added ${this.wallet.amount} to ${this.wallet.id}`, message: `Success! Added ${this.wallet.amount} to ${this.wallet.id}`,
icon: null icon: null
+88 -89
View File
@@ -1,16 +1,13 @@
/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, decryptLnurlPayAES */ window.app = Vue.createApp({
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data: function () {
return { return {
updatePayments: false, updatePayments: false,
origin: window.location.origin, origin: window.location.origin,
wallet: LNbits.map.wallet(window.wallet),
user: LNbits.map.user(window.user), user: LNbits.map.user(window.user),
exportUrl: `${window.location.origin}/wallet?usr=${window.user.id}&wal=${window.wallet.id}`,
receive: { receive: {
show: false, show: false,
status: 'pending', status: 'pending',
@@ -56,7 +53,9 @@ new Vue({
update: { update: {
name: null, name: null,
currency: null currency: null
} },
inkeyHidden: true,
adminkeyHidden: true
} }
}, },
computed: { computed: {
@@ -155,7 +154,7 @@ new Vue({
if (typeof response.data.lnurl_response === 'string') { if (typeof response.data.lnurl_response === 'string') {
// failure // failure
this.$q.notify({ Quasar.Notify.create({
timeout: 5000, timeout: 5000,
type: 'warning', type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`, message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
@@ -164,7 +163,7 @@ new Vue({
return return
} else if (response.data.lnurl_response === true) { } else if (response.data.lnurl_response === true) {
// success // success
this.$q.notify({ Quasar.Notify.create({
timeout: 5000, timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`, message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true spinner: true
@@ -203,80 +202,79 @@ new Vue({
? mapping[valid_error] ? mapping[valid_error]
: `ERROR: Camera error (${error.name})` : `ERROR: Camera error (${error.name})`
this.parse.camera.show = false this.parse.camera.show = false
this.$q.notify({ Quasar.Notify.create({
message: camera_error, message: camera_error,
type: 'negative' type: 'negative'
}) })
} }
}, },
lnurlScan() {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
Quasar.Notify.create({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
},
decodeQR: function (res) { decodeQR: function (res) {
this.parse.data.request = res this.parse.data.request = res[0].rawValue
this.decodeRequest() this.decodeRequest()
this.parse.camera.show = false this.parse.camera.show = false
}, },
decodeRequest: function () { decodeRequest: function () {
this.parse.show = true this.parse.show = true
let req = this.parse.data.request.toLowerCase() this.parse.data.request = this.parse.data.request.trim().toLowerCase()
if (this.parse.data.request.toLowerCase().startsWith('lightning:')) { let req = this.parse.data.request
this.parse.data.request = this.parse.data.request.slice(10) if (req.startsWith('lightning:')) {
} else if (this.parse.data.request.toLowerCase().startsWith('lnurl:')) { this.parse.data.request = req.slice(10)
this.parse.data.request = this.parse.data.request.slice(6) } else if (req.startsWith('lnurl:')) {
} else if (req.indexOf('lightning=lnurl1') !== -1) { this.parse.data.request = req.slice(6)
this.parse.data.request = this.parse.data.request } else if (req.includes('lightning=lnurl1')) {
.split('lightning=')[1] this.parse.data.request = req.split('lightning=')[1].split('&')[0]
.split('&')[0]
} }
req = this.parse.data.request
if ( if (req.startsWith('lnurl1') || req.match(/[\w.+-~_]+@[\w.+-~_]/)) {
this.parse.data.request.toLowerCase().startsWith('lnurl1') || this.lnurlScan()
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
return return
} }
@@ -294,7 +292,7 @@ new Vue({
try { try {
invoice = decode(this.parse.data.request) invoice = decode(this.parse.data.request)
} catch (error) { } catch (error) {
this.$q.notify({ Quasar.Notify.create({
timeout: 3000, timeout: 3000,
type: 'warning', type: 'warning',
message: error + '.', message: error + '.',
@@ -320,7 +318,7 @@ new Vue({
var expireDate = new Date( var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000 (invoice.data.time_stamp + tag.value) * 1000
) )
cleanInvoice.expireDate = Quasar.utils.date.formatDate( cleanInvoice.expireDate = Quasar.date.formatDate(
expireDate, expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ' 'YYYY-MM-DDTHH:mm:ss.SSSZ'
) )
@@ -332,7 +330,7 @@ new Vue({
this.parse.invoice = Object.freeze(cleanInvoice) this.parse.invoice = Object.freeze(cleanInvoice)
}, },
payInvoice: function () { payInvoice: function () {
let dismissPaymentMsg = this.$q.notify({ let dismissPaymentMsg = Quasar.Notify.create({
timeout: 0, timeout: 0,
message: this.$t('processing_payment') message: this.$t('processing_payment')
}) })
@@ -365,7 +363,7 @@ new Vue({
}) })
}, },
payLnurl: function () { payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({ let dismissPaymentMsg = Quasar.Notify.create({
timeout: 0, timeout: 0,
message: 'Processing payment...' message: 'Processing payment...'
}) })
@@ -398,7 +396,7 @@ new Vue({
if (response.data.success_action) { if (response.data.success_action) {
switch (response.data.success_action.tag) { switch (response.data.success_action.tag) {
case 'url': case 'url':
this.$q.notify({ Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`, message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
caption: response.data.success_action.description, caption: response.data.success_action.description,
html: true, html: true,
@@ -408,7 +406,7 @@ new Vue({
}) })
break break
case 'message': case 'message':
this.$q.notify({ Quasar.Notify.create({
message: response.data.success_action.message, message: response.data.success_action.message,
type: 'positive', type: 'positive',
timeout: 0, timeout: 0,
@@ -425,7 +423,7 @@ new Vue({
) )
) )
.then(value => { .then(value => {
this.$q.notify({ Quasar.Notify.create({
message: value, message: value,
caption: response.data.success_action.description, caption: response.data.success_action.description,
html: true, html: true,
@@ -447,7 +445,7 @@ new Vue({
}) })
}, },
authLnurl: function () { authLnurl: function () {
let dismissAuthMsg = this.$q.notify({ let dismissAuthMsg = Quasar.Notify.create({
timeout: 10, timeout: 10,
message: 'Performing authentication...' message: 'Performing authentication...'
}) })
@@ -456,7 +454,7 @@ new Vue({
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback) .authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
.then(_ => { .then(_ => {
dismissAuthMsg() dismissAuthMsg()
this.$q.notify({ Quasar.Notify.create({
message: `Authentication successful.`, message: `Authentication successful.`,
type: 'positive', type: 'positive',
timeout: 3500 timeout: 3500
@@ -466,7 +464,7 @@ new Vue({
.catch(err => { .catch(err => {
dismissAuthMsg() dismissAuthMsg()
if (err.response.data.reason) { if (err.response.data.reason) {
this.$q.notify({ Quasar.Notify.create({
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`, message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
caption: err.response.data.reason, caption: err.response.data.reason,
type: 'warning', type: 'warning',
@@ -481,7 +479,7 @@ new Vue({
LNbits.api LNbits.api
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data) .request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
.then(_ => { .then(_ => {
this.$q.notify({ Quasar.Notify.create({
message: `Wallet updated.`, message: `Wallet updated.`,
type: 'positive', type: 'positive',
timeout: 3500 timeout: 3500
@@ -499,7 +497,7 @@ new Vue({
LNbits.api LNbits.api
.deleteWallet(this.g.wallet) .deleteWallet(this.g.wallet)
.then(_ => { .then(_ => {
this.$q.notify({ Quasar.Notify.create({
timeout: 3000, timeout: 3000,
message: `Wallet deleted!`, message: `Wallet deleted!`,
spinner: true spinner: true
@@ -513,10 +511,11 @@ new Vue({
fetchBalance: function () { fetchBalance: function () {
LNbits.api.getWallet(this.g.wallet).then(response => { LNbits.api.getWallet(this.g.wallet).then(response => {
this.balance = Math.floor(response.data.balance / 1000) this.balance = Math.floor(response.data.balance / 1000)
EventHub.$emit('update-wallet-balance', [ document.dispatchEvent(
this.g.wallet.id, new CustomEvent('updateWalletBalance', {
this.balance detail: [this.g.wallet.id, this.balance]
]) })
)
}) })
if (this.g.wallet.currency) { if (this.g.wallet.currency) {
this.updateFiatBalance() this.updateFiatBalance()
@@ -542,7 +541,7 @@ new Vue({
pasteToTextArea: function () { pasteToTextArea: function () {
this.$refs.textArea.focus() // Set cursor to textarea this.$refs.textArea.focus() // Set cursor to textarea
navigator.clipboard.readText().then(text => { navigator.clipboard.readText().then(text => {
this.$refs.textArea.value = text this.parse.data.request = text.trim()
}) })
} }
}, },
+17 -9
View File
@@ -207,23 +207,24 @@ video {
} }
// qrcode // qrcode
.qrcode__wrapper canvas { .qrcode__wrapper {
position: relative; position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode__wrapper canvas {
width: 100% !important; // important to override qrcode inline width width: 100% !important; // important to override qrcode inline width
max-width: 100%; height: 100% !important;
max-height: 100%; max-width: 350px; // default width of <lnbits-qrcode> component
} }
.qrcode__image { .qrcode__image {
position: absolute;
max-width: 52px;
width: 15%; width: 15%;
height: 15%;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
left: 50%;
overflow: hidden; overflow: hidden;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
padding: 0.2rem; padding: 0.2rem;
border-radius: 0.2rem; border-radius: 0.2rem;
} }
@@ -231,3 +232,10 @@ video {
.whitespace-pre-line { .whitespace-pre-line {
white-space: pre-line; white-space: pre-line;
} }
.q-carousel__slide {
background-size: contain;
background-repeat: no-repeat;
}
.q-dialog__inner--minimized {
padding: 12px;
}

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