Compare commits

..
116 Commits
Author SHA1 Message Date
dni ⚡andGitHub aa63879ff5 chore: update to v1.0.0-rc6 (#2784) 2024-11-28 18:01:08 +01:00
Vlad StanandGitHub 0547abe54a [feat] install extensions from dir (#2781)
* feat: search ext dir and install
* fix: `upgrade_hash` logic
* chore: clean-up `upgrade_hash` logic
* fix: screen refresh
* fix: ignore non-ext dirs
* fix: ext migration
2024-11-28 11:51:29 +01:00
dni ⚡andGitHub f5ccf5c157 fix: stuck pay_invoice (#2783)
db connection wasnt passed to `get_user_active_extensions_id`. and in
some contexts nested db connection will stuck the server
2024-11-28 11:50:15 +01:00
e3d6b6befa fix: some ui bugs (#2782)
remove splitter constrain

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

- added the ability to receive self payments on the node

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

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

---------

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

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

* feat: add notifications to nfc read and payment process

* feat: display sat and fiat amount on receive invoice

* feat: add notifications for non-lnurl nfc tags

* removed unnecesary payment updates

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

* fix: lnurl response status error

* fix: abort nfc reading on receive dialog hid

* feat: dismiss tap suggestion when nfc tag read successfully

* update: NFC supported chip

* remove console.log

* add: function return type

* test: happy path for api_payment_pay_with_nfc

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

* explicit lnurl withdraw for payment

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

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

---------

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

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

---------

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

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

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

---------

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

---------

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

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

---------

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

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

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

I don't see why this exists

---------

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

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

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

Wrong string case search

Closes #1599

* optimize the code

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

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

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

* chore: bundle

* fixup!

---------

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

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

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

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

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

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

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

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

* feat: paid_invoices_stream

* feat: proper invoice and payment status check

* feat: authenticate over insecure channel aswell

* chore: lint

* docs: add more setup instructions

* chore: add `boltz_client_cert` in frontend

* feat: populate fee_msat in get_payment_status and get_invoice_status

* fixup!

* chore: bundle

* added boltz logo

* add BoltzWallet to __all__

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

* chore: update protos

* feat: pass description when creating swap

* fixup!

* chore: bundle

---------

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

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

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

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

* cancel pending_payments_lookup_task on cleanup

* Rename subscription_timeout_task to timeout_task

* ensure preimage is not None

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

* fetch account info when possible

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

* fundle

* make format

* fix formatting

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

* format

* fix lint

* format

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

* fix padding

* fix documentation for _verify_event method

* refactoring and fixes

* Split NWCWallet - NWCConnection

* refactor class methods into helpers

* update bundle

* format

* catch NWCError failure codes

* format and fix

* chore: bundle

* add example

* typos

---------

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

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

* vlad picks
* refactor get_placeholder

---------

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

---------

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

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

* chore: `make format`

* chore: comment

* Update lnbits/helpers.py

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

* Update lnbits/helpers.py

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

* chore: code format

---------

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

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

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

---------

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

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

- database migration
- remove deleting of payments, failed payments stay
2024-07-24 16:47:26 +03:00
Pavol RusnakandGitHub b14d36a0aa chore(deps): replace python-jose with pyjwt (#2591)
python-jose had no release since 3 years
2024-07-24 10:42:47 +02:00
212 changed files with 46842 additions and 17682 deletions
+21 -4
View File
@@ -9,8 +9,8 @@
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this to make LNbits use this config file again.
LNBITS_ADMIN_UI=false
# Disable this and clear `settings` table from database to make LNbits use this config file again.
LNBITS_ADMIN_UI=true
# Change theme
LNBITS_SITE_TITLE="LNbits"
@@ -28,7 +28,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, 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
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -87,6 +87,12 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits"
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
@@ -112,18 +118,29 @@ LNBITS_DENOMINATION=sats
ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw
# NWCWalllet
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
# LnTipsWallet
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips
# 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 ##########
######################################
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
+4 -1
View File
@@ -46,7 +46,10 @@ runs:
- name: Install the project dependencies
shell: bash
run: poetry install
run: |
poetry install
# needed for conv tests
poetry add psycopg2-binary
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
+3 -4
View File
@@ -28,11 +28,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v24
- uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-23.11
- uses: cachix/cachix-action@v13
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
nix_path: nixpkgs=channel:nixos-24.05
- uses: cachix/cachix-action@v15
with:
name: lnbits
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
+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 }}
+6 -2
View File
@@ -35,6 +35,7 @@ __bundle__
coverage.xml
node_modules
lnbits/static/bundle.js
lnbits/static/bundle-components.js
lnbits/static/bundle.css
lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old
@@ -49,8 +50,11 @@ fly.toml
lnbits-backup.zip
# Ignore extensions (post installable extension PR)
extensions
upgrades/
/lnbits/extensions
/upgrades/
# builded python package
dist
# jetbrains
.idea
+1
View File
@@ -10,6 +10,7 @@
**/lnbits/static/vendor
**/lnbits/static/bundle.*
**/lnbits/static/bundle-components.*
**/lnbits/static/css/*
flake.lock
+13 -7
View File
@@ -38,25 +38,32 @@ checkeditorconfig:
dev:
poetry run lnbits --reload
docker:
docker build -t lnbits/lnbits .
test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/wallets
test-unit:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/unit
test-api:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/api
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/regtest
@@ -71,6 +78,7 @@ test-migration:
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
LNBITS_ADMIN_UI=False \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
@@ -80,6 +88,7 @@ migration:
poetry run python tools/conv.py
openapi:
LNBITS_ADMIN_UI=False \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
@@ -99,24 +108,21 @@ sass:
bundle:
npm install
npm run sass
npm run vendor_copy
npm run vendor_json
npm run bundle
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
npm run vendor_bundle_css
npm run vendor_minify_css
npm run vendor_bundle_js
npm run vendor_minify_js
checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
make bundle
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
@echo "Bundle is OK"
rm lnbits/static/bundle.min.js.old
rm lnbits/static/bundle.min.css.old
rm lnbits/static/bundle-components.min.js.old
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
+14 -2
View File
@@ -11,13 +11,16 @@ Thanks for contributing :)
# Run
This starts the lnbits uvicorn server
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install poetry and other dependencies.
Then you can start LNbits uvicorn server with:
```bash
poetry run lnbits
```
This starts the lnbits uvicorn with hot reloading.
Or you can use the following to start uvicorn with hot reloading enabled:
```bash
make dev
@@ -25,6 +28,15 @@ make dev
poetry run lnbits --reload
```
You might need the following extra dependencies on clean installation of Debian:
```
sudo apt install nodejs
sudo apt install npm
npm install
sudo apt-get install autoconf libtool libpg-dev
```
# Precommit hooks
This ensures that all commits adhere to the formatting and linting rules.
+23 -21
View File
@@ -10,22 +10,17 @@ The following sections explain how to install LNbits using varions package manag
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)
Make sure you have Python 3.9 or 3.10 installed.
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
### install python on ubuntu
### Verify Python version
```sh
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
python3 --version
```
### install poetry
### Install Poetry
```sh
curl -sSL https://install.python-poetry.org | python3 -
@@ -38,13 +33,8 @@ git clone https://github.com/lnbits/lnbits.git
cd lnbits
git checkout main
# Next command, you can exchange with python3.10 or newer versions.
# Identify your version with python3 --version and specify in the next line
# command is only needed when your default python is not ^3.9 or ^3.10
poetry env use python3.9
poetry install --only main
mkdir data
cp .env.example .env
# set funding source amongst other options
nano .env
@@ -70,7 +60,19 @@ poetry install --only main
# 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
# 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
```
## Option 3: Docker
## Option 4: Docker
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
```
## 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.
@@ -373,7 +375,7 @@ Install Apache2 and enable Apache2 mods:
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy-http
a2enmod headers ssl proxy proxy_http
```
Create a SSL certificate with LetsEncrypt:
@@ -412,7 +414,7 @@ EOF
Restart Apache2:
```sh
service restart apache2
service apache2 restart
```
## Running behind an Nginx reverse proxy over HTTPS
@@ -466,7 +468,7 @@ EOF
Restart nginx:
```sh
service restart nginx
service nginx restart
```
## Using https without reverse proxy
+32
View File
@@ -93,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_ACCESS_TOKEN`: AlbyAccessToken
### Boltz
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: lnbits
### ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
@@ -110,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_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_ENDPOINT`: ws://127.0.0.1:12000
### Nostr Wallet Connect (NWC)
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
Generated
+16 -16
View File
@@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
@@ -26,11 +26,11 @@
]
},
"locked": {
"lastModified": 1698974481,
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github"
},
"original": {
@@ -41,16 +41,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1702233072,
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
"lastModified": 1723938990,
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-23.11",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
@@ -66,11 +66,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1702334837,
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
"lastModified": 1724134185,
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"type": "github"
},
"original": {
@@ -122,11 +122,11 @@
]
},
"locked": {
"lastModified": 1699786194,
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
"lastModified": 1719749022,
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github"
},
"original": {
+2 -17
View File
@@ -2,7 +2,7 @@
description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
poetry2nix = {
url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
@@ -30,25 +30,10 @@
meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
coincurve = prev.coincurve.override { preferWheel = true; };
protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; };
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
dnspython = prev.dnspython.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.hatchling ]; }
);
jinja2 = prev.jinja2.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.flit-core ]; }
);
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
+24
View File
@@ -0,0 +1,24 @@
from .core.services import create_invoice, pay_invoice
from .decorators import (
check_admin,
check_super_user,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from .exceptions import InvoiceError, PaymentError
__all__ = [
# decorators
"require_admin_key",
"require_invoice_key",
"check_admin",
"check_super_user",
"check_user_exists",
# services
"pay_invoice",
"create_invoice",
# exceptions
"PaymentError",
"InvoiceError",
]
+61 -29
View File
@@ -6,7 +6,7 @@ import shutil
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, List, Optional
from typing import Callable, Optional
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -17,16 +17,22 @@ from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import (
get_dbversions,
get_db_version,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.tasks import ( # watchdog_task
audit_queue,
killswitch_task,
purge_audit_data,
wait_for_audit_data,
wait_for_paid_invoices,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
@@ -44,15 +50,10 @@ from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases
from .core import init_core_routers
from .core.db import core_app_extra
from .core.models.extensions import Extension, ExtensionMeta, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension
from .extension_manager import (
Extension,
InstallableExtension,
get_valid_extensions,
version_parse,
)
from .middleware import (
AuditMiddleware,
CustomGZipMiddleware,
ExtensionsRedirectMiddleware,
InstalledExtensionMiddleware,
@@ -153,6 +154,8 @@ def create_app() -> FastAPI:
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
)
app.add_middleware(AuditMiddleware, audit_queue=audit_queue)
# required for SSO login
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
@@ -206,7 +209,7 @@ async def check_funding_source() -> None:
break
retry_counter += 1
sleep_time = 0.25 * (2**retry_counter)
sleep_time = min(0.25 * (2**retry_counter), 60)
logger.warning(
f"Retrying connection to backend in {sleep_time} seconds... "
f"({retry_counter}/{max_retries})"
@@ -229,7 +232,7 @@ async def check_installed_extensions(app: FastAPI):
re-created. The 'data' directory (where the '.zip' files live) is expected to
persist state. Zips that are missing will be re-downloaded.
"""
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
installed_extensions = await build_all_installed_extensions_list(False)
for ext in installed_extensions:
@@ -243,6 +246,7 @@ async def check_installed_extensions(app: FastAPI):
)
except Exception as e:
logger.warning(e)
await deactivate_extension(ext.id)
logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
)
@@ -254,7 +258,7 @@ async def check_installed_extensions(app: FastAPI):
async def build_all_installed_extensions_list(
include_deactivated: Optional[bool] = True,
) -> List[InstallableExtension]:
) -> list[InstallableExtension]:
"""
Returns a list of all the installed extensions plus the extensions that
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
@@ -262,6 +266,25 @@ async def build_all_installed_extensions_list(
installed_extensions = await get_installed_extensions()
settings.lnbits_all_extensions_ids = {e.id for e in installed_extensions}
for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
try:
if not ext_dir.is_dir():
continue
ext_id = ext_dir.name
if ext_id in settings.lnbits_all_extensions_ids:
continue
ext_info = InstallableExtension.from_ext_dir(ext_id)
if not ext_info:
continue
installed_extensions.append(ext_info)
await create_installed_extension(ext_info)
current_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, current_version)
except Exception as e:
logger.warning(e)
for ext_id in settings.lnbits_extensions_default_install:
if ext_id in settings.lnbits_all_extensions_ids:
continue
@@ -274,8 +297,13 @@ async def build_all_installed_extensions_list(
release = next((e for e in ext_releases if e.is_version_compatible), None)
if release:
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension(
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
id=ext_id,
name=ext_id,
version=release.version,
icon=release.icon,
meta=ext_meta,
)
installed_extensions.append(ext_info)
@@ -306,18 +334,16 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
await add_installed_extension(ext)
await update_installed_extension_state(ext_id=ext.id, active=True)
extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension)
current_version = (await get_dbversions()).get(ext.id, 0)
await migrate_extension_database(extension, current_version)
current_version = await get_db_version(ext.id)
await migrate_extension_database(ext, current_version)
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
ext.notify_upgrade(extension.upgrade_hash)
def register_custom_extensions_path():
@@ -335,8 +361,14 @@ def register_custom_extensions_path():
+ f" '{settings.lnbits_extensions_path}/extensions'"
)
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
extensions_dir = Path(settings.lnbits_extensions_path, "extensions")
Path(extensions_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(extensions_dir))
upgrades_dir = Path(settings.lnbits_extensions_path, "upgrades")
shutil.rmtree(upgrades_dir, True)
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
def register_new_ext_routes(app: FastAPI) -> Callable:
@@ -380,24 +412,22 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
)
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
settings.lnbits_extensions_redirects = [
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code
]
for r in ext_redirects:
r["ext_id"] = ext.code
settings.lnbits_extensions_redirects.append(r)
ext_redirects = (
getattr(ext_module, f"{ext.code}_redirect_paths")
if hasattr(ext_module, f"{ext.code}_redirect_paths")
else []
)
logger.trace(f"adding route for extension {ext_module}")
settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
logger.trace(f"Adding route for extension {ext_module}.")
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI):
await check_installed_extensions(app)
for ext in get_valid_extensions(False):
for ext in await get_valid_extensions(False):
try:
register_ext_routes(app, ext)
except Exception as exc:
@@ -410,6 +440,7 @@ def register_async_tasks(app: FastAPI):
if not settings.lnbits_extensions_deactivate_all:
create_task(check_and_register_extensions(app))
create_permanent_task(wait_for_audit_data)
create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
@@ -423,6 +454,7 @@ def register_async_tasks(app: FastAPI):
# TODO: implement watchdog properly
# create_permanent_task(watchdog_task)
create_permanent_task(killswitch_task)
create_permanent_task(purge_audit_data)
# server logs for websocket
if settings.lnbits_admin_ui:
+44 -99
View File
@@ -3,8 +3,7 @@ import importlib
import time
from functools import wraps
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.parse import urlparse
from typing import Optional
import click
import httpx
@@ -12,7 +11,27 @@ from fastapi.exceptions import HTTPException
from loguru import logger
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.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.models.extensions import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
api_install_extension,
@@ -21,29 +40,6 @@ from lnbits.core.views.extension_api import (
from lnbits.settings import settings
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):
@wraps(f)
@@ -122,54 +118,12 @@ def database_migrate():
loop.run_until_complete(migrate_databases())
async def db_migrate():
task = asyncio.create_task(migrate_databases())
await task
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
# 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")
@coro
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
click.echo(await get_dbversions(conn))
click.echo(await get_db_versions(conn))
@db.command("cleanup-wallets")
@@ -216,10 +170,13 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
@db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro
async def database_revert_payment(checking_id: str, pending: bool = True):
"""Mark wallet as deleted"""
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
payment = await get_payment(checking_id=checking_id, conn=conn)
payment.status = PaymentState.PENDING
await update_payment(payment, conn=conn)
click.echo(f"Payment '{checking_id}' marked as pending.")
@db.command("cleanup-accounts")
@@ -276,7 +233,7 @@ async def check_invalid_payments(
click.echo("Funding source: " + str(funding_source))
# payments that are settled in the DB, but not at the Funding source level
invalid_payments: List[Payment] = []
invalid_payments: list[Payment] = []
invalid_wallets = {}
for db_payment in settled_db_payments:
if verbose:
@@ -313,12 +270,6 @@ async def check_invalid_payments(
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
@extensions.command("list")
@coro
async def extensions_list():
@@ -328,8 +279,10 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list():
assert ext.installed_release, f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.installed_release.version})")
assert (
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")
@@ -378,7 +331,7 @@ async def extensions_update(
if extension and all_extensions:
click.echo("Only one of extension ID or the '--all' flag must be specified")
return
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -452,7 +405,7 @@ async def extensions_install(
):
"""Install a extension"""
click.echo(f"Installing {extension}... {repo_index}")
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -480,7 +433,7 @@ async def extensions_uninstall(
"""Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...")
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -512,7 +465,7 @@ async def install_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
try:
release = await _select_release(extension, repo_index, source_repo)
if not release:
@@ -541,7 +494,7 @@ async def update_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
try:
click.echo(f"Updating '{extension}' extension.")
installed_ext = await get_installed_extension(extension)
@@ -554,7 +507,7 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
assert (
installed_ext.installed_release
installed_ext.meta and installed_ext.meta.installed_release
), "Cannot find previously installed release. Please uninstall first."
release = await _select_release(extension, repo_index, source_repo)
@@ -562,7 +515,7 @@ async def update_extension(
return False, "No release selected."
if (
release.version == installed_ext.installed_version
and release.source_repo == installed_ext.installed_release.source_repo
and release.source_repo == installed_ext.meta.installed_release.source_repo
):
click.echo(f"Extension '{extension}' already up to date.")
return False, "Already up to date"
@@ -661,7 +614,7 @@ async def _call_install_extension(
)
resp.raise_for_status()
else:
await api_install_extension(data, User(id="mock_id"))
await api_install_extension(data)
async def _call_uninstall_extension(
@@ -675,7 +628,7 @@ async def _call_uninstall_extension(
)
resp.raise_for_status()
else:
await api_uninstall_extension(extension, User(id="mock_id"))
await api_uninstall_extension(extension)
async def _can_run_operation(url) -> bool:
@@ -694,7 +647,7 @@ async def _can_run_operation(url) -> bool:
elif url:
click.echo(
"The option '--url' has been provided,"
+ f" but no server found runnint at '{url}'"
f" but no server found running at '{url}'"
)
return False
@@ -709,11 +662,3 @@ async def _is_lnbits_started(url: Optional[str]):
return True
except Exception:
return False
def _is_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+5
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db
from .views.admin_api import admin_router
from .views.api import api_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.extension_api import extension_router
@@ -38,3 +39,7 @@ def init_core_routers(app: FastAPI):
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
__all__ = ["core_app", "core_app_extra", "db"]
-1336
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
from .audit import create_audit_entry
from .db_versions import (
delete_dbversion,
get_db_version,
get_db_versions,
update_migration_version,
)
from .extensions import (
create_installed_extension,
create_user_extension,
delete_installed_extension,
drop_extension_db,
get_installed_extension,
get_installed_extensions,
get_user_active_extensions_ids,
get_user_extension,
get_user_extensions,
update_installed_extension,
update_installed_extension_state,
update_user_extension,
)
from .payments import (
DateTrunc,
check_internal,
create_payment,
delete_expired_invoices,
delete_wallet_payment,
get_latest_payments_by_extension,
get_payment,
get_payments,
get_payments_history,
get_payments_paginated,
get_standalone_payment,
get_wallet_payment,
is_internal_status_success,
mark_webhook_sent,
update_payment,
update_payment_checking_id,
update_payment_extra,
)
from .settings import (
create_admin_settings,
delete_admin_settings,
get_admin_settings,
get_super_settings,
update_admin_settings,
update_super_user,
)
from .tinyurl import create_tinyurl, delete_tinyurl, get_tinyurl, get_tinyurl_by_url
from .users import (
create_account,
delete_account,
delete_accounts_no_wallets,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email,
get_accounts,
get_user,
get_user_from_account,
update_account,
)
from .wallets import (
create_wallet,
delete_unused_wallets,
delete_wallet,
delete_wallet_by_id,
force_delete_wallet,
get_total_balance,
get_wallet,
get_wallet_for_key,
get_wallets,
remove_deleted_wallets,
update_wallet,
)
from .webpush import (
create_webpush_subscription,
delete_webpush_subscription,
delete_webpush_subscriptions,
get_webpush_subscription,
get_webpush_subscriptions_for_user,
)
__all__ = [
# audit
"create_audit_entry",
# db_versions
"get_db_version",
"get_db_versions",
"update_migration_version",
"delete_dbversion",
# extensions
"create_installed_extension",
"create_user_extension",
"delete_installed_extension",
"drop_extension_db",
"get_installed_extension",
"get_installed_extensions",
"get_user_active_extensions_ids",
"get_user_extension",
"update_installed_extension",
"update_installed_extension_state",
"update_user_extension",
"get_user_extensions",
# payments
"DateTrunc",
"check_internal",
"create_payment",
"delete_expired_invoices",
"delete_wallet_payment",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
"get_payments_history",
"get_payments_paginated",
"get_standalone_payment",
"get_wallet_payment",
"is_internal_status_success",
"mark_webhook_sent",
"update_payment",
"update_payment_checking_id",
"update_payment_extra",
# settings
"create_admin_settings",
"delete_admin_settings",
"get_admin_settings",
"get_super_settings",
"update_admin_settings",
"update_super_user",
# tinyurl
"create_tinyurl",
"delete_tinyurl",
"get_tinyurl",
"get_tinyurl_by_url",
# users
"create_account",
"delete_account",
"delete_accounts_no_wallets",
"get_account",
"get_account_by_email",
"get_account_by_pubkey",
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_user",
"get_user_from_account",
"update_account",
# wallets
"create_wallet",
"delete_unused_wallets",
"delete_wallet",
"delete_wallet_by_id",
"force_delete_wallet",
"get_total_balance",
"get_wallet",
"get_wallet_for_key",
"get_wallets",
"remove_deleted_wallets",
"update_wallet",
# webpush
"create_webpush_subscription",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"get_webpush_subscription",
"get_webpush_subscriptions_for_user",
]
+84
View File
@@ -0,0 +1,84 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditCountStat
from lnbits.db import Connection, Filters, Page
async def create_audit_entry(
entry: AuditEntry,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("audit", entry)
async def get_audit_entries(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AuditEntry]:
return await (conn or db).fetch_page(
"SELECT * from audit",
[],
{},
filters=filters,
model=AuditEntry,
)
async def delete_expired_audit_entries(
conn: Optional[Connection] = None,
):
await (conn or db).execute(
f"""
DELETE from audit
WHERE delete_at < {db.timestamp_now}
""",
)
async def get_count_stats(
field: str,
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if field not in ["request_method", "component", "response_code"]:
return []
if not filters:
filters = Filters()
clause = filters.where()
data = await (conn or db).fetchall(
query=f"""
SELECT {field} as field, count({field}) as total
FROM audit
{clause}
GROUP BY {field}
ORDER BY {field}
""",
values=filters.values(),
model=AuditCountStat,
)
return data
async def get_long_duration_stats(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if not filters:
filters = Filters()
clause = filters.where()
long_duration_paths = await (conn or db).fetchall(
query=f"""
SELECT path as field, max(duration) as total FROM audit
{clause}
GROUP BY path
ORDER BY total DESC
LIMIT 5
""",
values=filters.values(),
model=AuditCountStat,
)
return long_duration_paths
+39
View File
@@ -0,0 +1,39 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.db import Connection
from ..models import DbVersion
async def get_db_version(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[DbVersion]:
return await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :ext_id",
{"ext_id": ext_id},
model=DbVersion,
)
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
async def update_migration_version(conn, db_name, version):
await (conn or db).execute(
"""
INSERT INTO dbversions (db, version) VALUES (:db, :version)
ON CONFLICT (db) DO UPDATE SET version = :version
""",
{"db": db_name, "version": version},
)
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"""
DELETE FROM dbversions WHERE db = :ext
""",
{"ext": ext_id},
)
+137
View File
@@ -0,0 +1,137 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
UserExtension,
)
from lnbits.db import Connection, Database
async def create_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("installed_extensions", ext)
async def update_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update("installed_extensions", ext)
async def update_installed_extension_state(
*, ext_id: str, active: bool, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
UPDATE installed_extensions SET active = :active WHERE id = :ext
""",
{"ext": ext_id, "active": active},
)
async def delete_installed_extension(
*, ext_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
DELETE from installed_extensions WHERE id = :ext
""",
{"ext": ext_id},
)
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
row: dict = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :id",
{"id": ext_id},
)
# Check that 'ext_id' is a valid extension id and not a malicious string
assert row, f"Extension '{ext_id}' db version cannot be found"
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
return
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
# The `ext_id` value is verified above.
await (conn or db).execute(
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
)
async def get_installed_extension(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[InstallableExtension]:
extension = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = :id",
{"id": ext_id},
InstallableExtension,
)
return extension
async def get_installed_extensions(
active: Optional[bool] = None,
conn: Optional[Connection] = None,
) -> list[InstallableExtension]:
where = "WHERE active = :active" if active is not None else ""
values = {"active": active} if active is not None else {}
all_extensions = await (conn or db).fetchall(
f"SELECT * FROM installed_extensions {where}",
values,
model=InstallableExtension,
)
return all_extensions
async def get_user_extension(
user_id: str, extension: str, conn: Optional[Connection] = None
) -> Optional[UserExtension]:
return await (conn or db).fetchone(
"""
SELECT * FROM extensions
WHERE "user" = :user AND extension = :ext
""",
{"user": user_id, "ext": extension},
model=UserExtension,
)
async def get_user_extensions(
user_id: str, conn: Optional[Connection] = None
) -> list[UserExtension]:
return await (conn or db).fetchall(
"""SELECT * FROM extensions WHERE "user" = :user""",
{"user": user_id},
model=UserExtension,
)
async def create_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
await (conn or db).insert("extensions", user_extension)
async def update_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
where = """WHERE extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where)
async def get_user_active_extensions_ids(
user_id: str, conn: Optional[Connection] = None
) -> list[str]:
exts = await (conn or db).fetchall(
"""
SELECT * FROM extensions WHERE "user" = :user AND active
""",
{"user": user_id},
UserExtension,
)
return [ext.extension for ext in exts]
+385
View File
@@ -0,0 +1,385 @@
from time import time
from typing import Literal, Optional
from lnbits.core.crud.wallets import get_total_balance, get_wallet
from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.db import DB_TYPE, SQLITE, Connection, Filters, Page
from ..models import (
CreatePayment,
Payment,
PaymentFilters,
PaymentHistoryPoint,
)
DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = {
"hour": "%Y-%m-%d %H:00:00",
"day": "%Y-%m-%d 00:00:00",
"month": "%Y-%m-01 00:00:00",
}
def update_payment_extra():
pass
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
return await (conn or db).fetchone(
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
{"checking_id": checking_id},
Payment,
)
async def get_standalone_payment(
checking_id_or_hash: str,
conn: Optional[Connection] = None,
incoming: Optional[bool] = False,
wallet_id: Optional[str] = None,
) -> Optional[Payment]:
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = {
"wallet_id": wallet_id,
"checking_id": checking_id_or_hash,
"hash": checking_id_or_hash,
}
if incoming:
clause = f"({clause}) AND amount > 0"
if wallet_id:
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
f"""
SELECT * FROM apipayments
WHERE {clause}
ORDER BY amount LIMIT 1
""",
values,
Payment,
)
return row
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
payment = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash
""",
{"wallet": wallet_id, "hash": payment_hash},
Payment,
)
return payment
async def get_latest_payments_by_extension(
ext_name: str, ext_id: str, limit: int = 5
) -> list[Payment]:
return await db.fetchall(
f"""
SELECT * FROM apipayments
WHERE status = '{PaymentState.SUCCESS}'
AND extra LIKE :ext_name
AND extra LIKE :ext_id
ORDER BY time DESC LIMIT {limit}
""",
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
Payment,
)
async def get_payments_paginated(
*,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
values: dict = {
"wallet_id": wallet_id,
"time": since,
}
clause: list[str] = []
if since is not None:
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
clause.append("wallet_id = :wallet_id")
if complete and pending:
pass
elif complete:
clause.append(
f"((amount > 0 AND status = '{PaymentState.SUCCESS}') OR amount < 0)"
)
elif pending:
clause.append(f"status = '{PaymentState.PENDING}'")
else:
pass
if outgoing and incoming:
pass
elif outgoing:
clause.append("amount < 0")
elif incoming:
clause.append("amount > 0")
else:
pass
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
clause.append("checking_id NOT LIKE 'temp_%'")
clause.append("checking_id NOT LIKE 'internal_%'")
return await (conn or db).fetch_page(
"SELECT * FROM apipayments",
clause,
values,
filters=filters,
model=Payment,
)
async def get_payments(
*,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
filters = filters or Filters()
filters.sortby = filters.sortby or "time"
filters.direction = filters.direction or "desc"
filters.limit = limit or filters.limit
filters.offset = offset or filters.offset
page = await get_payments_paginated(
wallet_id=wallet_id,
complete=complete,
pending=pending,
outgoing=outgoing,
incoming=incoming,
since=since,
exclude_uncheckable=exclude_uncheckable,
filters=filters,
conn=conn,
)
return page.data
async def delete_expired_invoices(
conn: Optional[Connection] = None,
) -> None:
# first we delete all invoices older than one month
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND time < {db.timestamp_placeholder("delta")}
""",
{"delta": int(time() - 2592000)},
)
# then we delete all invoices whose expiry date is in the past
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND expiry < {db.timestamp_placeholder("now")}
""",
{"now": int(time())},
)
async def create_payment(
checking_id: str,
data: CreatePayment,
status: PaymentState = PaymentState.PENDING,
conn: Optional[Connection] = None,
) -> Payment:
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists"
payment = Payment(
checking_id=checking_id,
status=status,
wallet_id=data.wallet_id,
payment_hash=data.payment_hash,
bolt11=data.bolt11,
amount=data.amount_msat,
memo=data.memo,
preimage=data.preimage,
expiry=data.expiry,
webhook=data.webhook,
fee=data.fee,
extra=data.extra or {},
)
await (conn or db).insert("apipayments", payment)
return payment
async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
{"new_id": new_checking_id, "old_id": checking_id},
)
async def update_payment(
payment: Payment,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
async def get_payments_history(
wallet_id: Optional[str] = None,
group: DateTrunc = "day",
filters: Optional[Filters] = None,
) -> list[PaymentHistoryPoint]:
if not filters:
filters = Filters()
if DB_TYPE == SQLITE and group in sqlite_formats:
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
elif group in ("day", "hour", "month"):
date_trunc = f"date_trunc('{group}', time)"
else:
raise ValueError(f"Invalid group value: {group}")
values = {
"wallet_id": wallet_id,
}
where = [
f"wallet_id = :wallet_id AND (status = '{PaymentState.SUCCESS}' OR amount < 0)"
]
transactions: list[dict] = await db.fetchall(
f"""
SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments
{filters.where(where)}
GROUP BY date
ORDER BY date DESC
""",
filters.values(values),
)
if wallet_id:
wallet = await get_wallet(wallet_id)
if wallet:
balance = wallet.balance_msat
else:
raise ValueError("Unknown wallet")
else:
balance = await get_total_balance()
# since we dont know the balance at the starting point,
# we take the current balance and walk backwards
results: list[PaymentHistoryPoint] = []
for row in transactions:
results.insert(
0,
PaymentHistoryPoint(
balance=balance,
date=row.get("date", 0),
income=row.get("income", 0),
spending=row.get("spending", 0),
),
)
balance -= row.get("income", 0) - row.get("spending", 0)
return results
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
{"checking_id": checking_id, "wallet": wallet_id},
)
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
"""
return await (conn or db).fetchone(
f"""
SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
""",
{"hash": payment_hash},
Payment,
)
async def is_internal_status_success(
payment_hash: str, conn: Optional[Connection] = None
) -> bool:
"""
Returns True if the internal payment was found and is successful,
"""
payment = await (conn or db).fetchone(
"""
SELECT * FROM apipayments
WHERE payment_hash = :payment_hash AND amount > 0
""",
{"payment_hash": payment_hash},
Payment,
)
if not payment:
return False
return payment.status == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = :status
WHERE payment_hash = :hash
""",
{"status": status, "hash": payment_hash},
)
+120
View File
@@ -0,0 +1,120 @@
import json
from typing import Any, Optional
from loguru import logger
from lnbits.core.db import db
from lnbits.settings import (
AdminSettings,
EditableSettings,
SettingsField,
SuperSettings,
settings,
)
async def get_super_settings() -> Optional[SuperSettings]:
data = await get_settings_by_tag("core")
if data:
super_user = await get_settings_field("super_user")
super_user_id = super_user.value if super_user else None
return SuperSettings(**{"super_user": super_user_id, **data})
return None
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
sets = await get_super_settings()
if not sets:
return None
row_dict = dict(sets)
row_dict.pop("super_user")
row_dict.pop("auth_all_methods")
admin_settings = AdminSettings(
is_super_user=is_super_user,
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
**row_dict,
)
return admin_settings
async def update_admin_settings(
data: EditableSettings, tag: Optional[str] = "core"
) -> None:
editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True))
for key, value in editable_settings.items():
try:
await set_settings_field(key, value, tag)
except Exception as exc:
logger.warning(exc)
logger.warning(f"Failed to update settings for '{tag}.{key}'.")
async def update_super_user(super_user: str) -> SuperSettings:
await set_settings_field("super_user", super_user)
settings = await get_super_settings()
assert settings, "updated super_user settings could not be retrieved"
return settings
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
data = {"super_user": super_user, **new_settings}
for key, value in data.items():
await set_settings_field(key, value)
settings = await get_super_settings()
assert settings, "created admin settings could not be retrieved"
return settings
async def get_settings_field(
id_: str, tag: Optional[str] = "core"
) -> Optional[SettingsField]:
row: dict = await db.fetchone(
"""
SELECT * FROM system_settings
WHERE id = :id AND tag = :tag
""",
{"id": id_, "tag": tag},
)
if not row:
return None
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
async def set_settings_field(
id_: str, value: Optional[Any], tag: Optional[str] = "core"
):
value = json.dumps(value) if value is not None else None
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
ON CONFLICT (id, tag) DO UPDATE SET value = :value
""",
{"id": id_, "value": value, "tag": tag or "core"},
)
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
rows: list[dict] = await db.fetchall(
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
)
if len(rows) == 0:
return None
data: dict[str, Any] = {}
for row in rows:
try:
data[row["id"]] = json.loads(row["value"]) if row["value"] else None
except Exception as _:
logger.warning(
f"""Failed to load settings value for '{tag}.{row["id"]}'."""
)
data.pop("super_user")
return data
+42
View File
@@ -0,0 +1,42 @@
from typing import Optional
import shortuuid
from lnbits.core.db import db
from ..models import TinyURL
async def create_tinyurl(domain: str, endless: bool, wallet: str):
tinyurl_id = shortuuid.uuid()[:8]
await db.execute(
"""
INSERT INTO tiny_url (id, url, endless, wallet)
VALUES (:tinyurl, :domain, :endless, :wallet)
""",
{"tinyurl": tinyurl_id, "domain": domain, "endless": endless, "wallet": wallet},
)
return await get_tinyurl(tinyurl_id)
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
return await db.fetchone(
"SELECT * FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
TinyURL,
)
async def get_tinyurl_by_url(url: str) -> list[TinyURL]:
return await db.fetchall(
"SELECT * FROM tiny_url WHERE url = :url",
{"url": url},
TinyURL,
)
async def delete_tinyurl(tinyurl_id: str):
await db.execute(
"DELETE FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
)
+174
View File
@@ -0,0 +1,174 @@
from datetime import datetime, timezone
from time import time
from typing import Optional
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import get_wallets
from lnbits.core.db import db
from lnbits.db import Connection, Filters, Page
from ..models import (
Account,
AccountFilters,
AccountOverview,
User,
)
async def create_account(
account: Optional[Account] = None,
conn: Optional[Connection] = None,
) -> Account:
if account:
account.validate_fields()
else:
now = datetime.now(timezone.utc)
account = Account(id=uuid4().hex, created_at=now, updated_at=now)
await (conn or db).insert("accounts", account)
return account
async def update_account(account: Account) -> Account:
account.updated_at = datetime.now(timezone.utc)
await db.update("accounts", account)
return account
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE from accounts WHERE id = :user",
{"user": user_id},
)
async def get_accounts(
filters: Optional[Filters[AccountFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AccountOverview]:
return await (conn or db).fetch_page(
"""
SELECT
accounts.id,
accounts.username,
accounts.email,
accounts.pubkey,
wallets.id as wallet_id,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
SUM((
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
)) as transaction_count,
(
SELECT COUNT(*) FROM wallets WHERE wallets.user = accounts.id
) as wallet_count,
MAX((
SELECT time FROM apipayments
WHERE wallet_id = wallets.id ORDER BY time DESC LIMIT 1
)) as last_payment
FROM accounts LEFT JOIN wallets ON accounts.id = wallets.user
""",
[],
{},
filters=filters,
model=AccountOverview,
group_by=["accounts.id"],
)
async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE id = :id",
{"id": user_id},
Account,
)
async def delete_accounts_no_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
f"""
DELETE FROM accounts
WHERE NOT EXISTS (
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
) AND (
(updated_at is null AND created_at < :delta)
OR updated_at < {db.timestamp_placeholder("delta")}
)
""",
{"delta": delta},
)
async def get_account_by_username(
username: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE username = :username",
{"username": username},
Account,
)
async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE pubkey = :pubkey",
{"pubkey": pubkey},
Account,
)
async def get_account_by_email(
email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :email",
{"email": email},
Account,
)
async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :value or username = :value",
{"value": username_or_email},
Account,
)
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
account = await get_account(user_id, conn)
if not account:
return None
return await get_user_from_account(account, conn)
async def get_user_from_account(
account: Account, conn: Optional[Connection] = None
) -> Optional[User]:
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
return User(
id=account.id,
email=account.email,
username=account.username,
pubkey=account.pubkey,
extra=account.extra,
created_at=account.created_at,
updated_at=account.updated_at,
extensions=extensions,
wallets=wallets,
admin=account.is_admin,
super_user=account.is_super_user,
has_password=account.password_hash is not None,
)
+157
View File
@@ -0,0 +1,157 @@
from datetime import datetime, timezone
from time import time
from typing import Optional
from uuid import uuid4
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.settings import settings
from ..models import Wallet
async def create_wallet(
*,
user_id: str,
wallet_name: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Wallet:
wallet_id = uuid4().hex
wallet = Wallet(
id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name,
user=user_id,
adminkey=uuid4().hex,
inkey=uuid4().hex,
)
await (conn or db).insert("wallets", wallet)
return wallet
async def update_wallet(
wallet: Wallet,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet)
return wallet
async def delete_wallet(
*,
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Optional[Connection] = None,
) -> None:
now = int(time())
await (conn or db).execute(
f"""
UPDATE wallets
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet AND "user" = :user
""",
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
)
async def force_delete_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
)
async def delete_wallet_by_id(
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[int]:
now = int(time())
result = await (conn or db).execute(
f"""
UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet
""",
{"wallet": wallet_id, "now": now},
)
return result.rowcount
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
async def delete_unused_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
"""
DELETE FROM wallets
WHERE (
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
) = 0 AND (
(updated_at is null AND created_at < :delta)
OR updated_at < :delta
)
""",
{"delta": delta},
)
async def get_wallet(
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Optional[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else ""
return await (conn or db).fetchone(
f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet {where}
""",
{"wallet": wallet_id, "deleted": deleted},
Wallet,
)
async def get_wallets(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else ""
return await (conn or db).fetchall(
f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user {where}
""",
{"user": user_id, "deleted": deleted},
Wallet,
)
async def get_wallet_for_key(
key: str,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
return await (conn or db).fetchone(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)
AS balance_msat FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
""",
{"key": key},
Wallet,
)
async def get_total_balance(conn: Optional[Connection] = None):
result = await (conn or db).execute("SELECT SUM(balance) FROM balances")
row = result.mappings().first()
return row.get("balance", 0)
+59
View File
@@ -0,0 +1,59 @@
from typing import Optional
from lnbits.core.db import db
from ..models import WebPushSubscription
async def get_webpush_subscription(
endpoint: str, user: str
) -> Optional[WebPushSubscription]:
return await db.fetchone(
"""
SELECT * FROM webpush_subscriptions
WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
WebPushSubscription,
)
async def get_webpush_subscriptions_for_user(user: str) -> list[WebPushSubscription]:
return await db.fetchall(
"""SELECT * FROM webpush_subscriptions WHERE "user" = :user""",
{"user": user},
WebPushSubscription,
)
async def create_webpush_subscription(
endpoint: str, user: str, data: str, host: str
) -> WebPushSubscription:
await db.execute(
"""
INSERT INTO webpush_subscriptions (endpoint, "user", data, host)
VALUES (:endpoint, :user, :data, :host)
""",
{"endpoint": endpoint, "user": user, "data": data, "host": host},
)
subscription = await get_webpush_subscription(endpoint, user)
assert subscription, "Newly created webpush subscription couldn't be retrieved"
return subscription
async def delete_webpush_subscription(endpoint: str, user: str) -> int:
resp = await db.execute(
"""
DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
)
return resp.rowcount
async def delete_webpush_subscriptions(endpoint: str) -> int:
resp = await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint",
{"endpoint": endpoint},
)
return resp.rowcount
+83 -74
View File
@@ -1,42 +1,52 @@
import importlib
import re
from typing import Any, Optional
from urllib.parse import urlparse
from uuid import UUID
import httpx
from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_db_versions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.db import Connection
from lnbits.extension_manager import Extension
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
from .crud import update_migration_version
async def migrate_extension_database(
ext: InstallableExtension, current_version: Optional[DbVersion] = None
):
async def migrate_extension_database(ext: Extension, current_version):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db
except ImportError as exc:
logger.error(exc)
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
) from exc
raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
async def run_migration(
db: Connection, migrations_module: Any, db_name: str, current_version: int
db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
):
matcher = re.compile(r"^m(\d\d\d)_")
for key, migrate in migrations_module.__dict__.items():
for key, migrate in list(migrations_module.__dict__.items()):
match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_version:
if not current_version or version > current_version.version:
logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}")
await migrate(db)
@@ -48,68 +58,6 @@ async def run_migration(
await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(
ext_id: str, user: str, access_token: Optional[str] = None
):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
at the root level of their API.
"""
stopped = await _stop_extension_background_work(ext_id)
if not stopped:
# fallback to REST API call
await _stop_extension_background_work_via_api(ext_id, user, access_token)
async def _stop_extension_background_work(ext_id) -> bool:
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
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:
if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits")
@@ -119,3 +67,64 @@ def to_valid_user_id(user_id: str) -> UUID:
raise ValueError("Invalid hex string for User ID.") from exc
return UUID(hex=user_id[:32], version=4)
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_db_versions(conn)
core_version = next(
(v for v in current_versions if v.db == "core"),
DbVersion(db="core", version=0),
)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in await get_installed_extensions():
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
)
continue
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.id}: {e}")
logger.info("✔️ All migrations done.")
def is_valid_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+220 -56
View File
@@ -1,10 +1,12 @@
import datetime
import json
from time import time
from typing import Any
from loguru import logger
from sqlalchemy.exc import OperationalError
from lnbits import bolt11
from lnbits.db import Connection
async def m000_create_migrations_table(db):
@@ -100,9 +102,8 @@ async def m002_add_fields_to_apipayments(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
import json
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
result = await db.execute("SELECT * FROM apipayments")
rows = result.mappings().all()
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
@@ -113,15 +114,15 @@ async def m002_add_fields_to_apipayments(db):
new = row["memo"][len(prefix) :]
await db.execute(
"""
UPDATE apipayments SET extra = ?, memo = ?
WHERE checking_id = ? AND memo = ?
UPDATE apipayments SET extra = :extra, memo = :memo1
WHERE checking_id = :checking_id AND memo = :memo2
""",
(
json.dumps({"tag": ext}),
new,
row["checking_id"],
row["memo"],
),
{
"extra": json.dumps({"tag": ext}),
"memo1": new,
"checking_id": row["checking_id"],
"memo2": row["memo"],
},
)
break
except OperationalError:
@@ -212,19 +213,18 @@ async def m007_set_invoice_expiries(db):
Precomputes invoice expiry for existing pending incoming payments.
"""
try:
rows = await (
await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
)
).fetchall()
result = await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
)
rows = result.mappings().all()
if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
@@ -236,22 +236,17 @@ async def m007_set_invoice_expiries(db):
if invoice.expiry is None:
continue
expiration_date = datetime.datetime.fromtimestamp(
invoice.date + invoice.expiry
)
expiration_date = invoice.date + invoice.expiry
logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
"""
UPDATE apipayments SET expiry = ?
WHERE checking_id = ? AND amount > 0
f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = :checking_id AND amount > 0
""",
(
db.datetime_to_timestamp(expiration_date),
checking_id,
),
{"expiry": expiration_date, "checking_id": checking_id},
)
except Exception:
continue
@@ -347,30 +342,34 @@ async def m014_set_deleted_wallets(db):
Sets deleted column to wallets.
"""
try:
rows = await (
await db.execute(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
).fetchall()
result = await db.execute(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
rows = result.mappings().all()
for row in rows:
try:
user = row[2].split(":")[1]
adminkey = row[3].split(":")[1]
inkey = row[4].split(":")[1]
user = row["user"].split(":")[1]
adminkey = row["adminkey"].split(":")[1]
inkey = row["inkey"].split(":")[1]
await db.execute(
"""
UPDATE wallets SET
"user" = ?, adminkey = ?, inkey = ?, deleted = true
WHERE id = ?
"user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
WHERE id = :wallet
""",
(user, adminkey, inkey, row[0]),
{
"user": user,
"adminkey": adminkey,
"inkey": inkey,
"wallet": row.get("id"),
},
)
except Exception:
continue
@@ -456,17 +455,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
now = int(time())
await db.execute(
f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder}
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""",
(now,),
{"now": now},
)
await db.execute(
f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder}
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""",
(now,),
{"now": now},
)
except OperationalError as exc:
@@ -520,3 +519,168 @@ async def m020_add_column_column_to_user_extensions(db):
Adds extra column to user extensions.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
async def m021_add_success_failed_to_apipayments(db):
"""
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
"""
)
async def m026_update_payment_table(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN tag TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extension TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN created_at TIMESTAMP")
await db.execute("ALTER TABLE apipayments ADD COLUMN updated_at TIMESTAMP")
async def m027_update_apipayments_data(db: Connection):
result = None
try:
result = await db.execute("SELECT * FROM apipayments")
except Exception as exc:
logger.warning("Could not select, trying again after cache cleared.")
logger.debug(exc)
await db.execute("COMMIT")
result = await db.execute("SELECT * FROM apipayments")
payments = result.mappings().all()
for payment in payments:
tag = None
created_at = payment.get("time")
if payment.get("extra"):
extra = json.loads(payment.get("extra"))
tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at")
await db.execute(
f"""
UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id
""",
{
"tag": tag,
"created_at": created_at,
"checking_id": payment.get("checking_id"),
},
)
async def m028_update_settings(db: Connection):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS system_settings (
id TEXT PRIMARY KEY,
value TEXT,
tag TEXT NOT NULL DEFAULT 'core',
UNIQUE (id, tag)
);
"""
)
async def _insert_key_value(id_: str, value: Any):
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
""",
{"id": id_, "value": json.dumps(value), "tag": "core"},
)
row: dict = await db.fetchone("SELECT * FROM settings")
if row:
await _insert_key_value("super_user", row["super_user"])
editable_settings = json.loads(row["editable_settings"])
for key, value in editable_settings.items():
await _insert_key_value(key, value)
await db.execute("drop table settings")
async def m029_create_audit_table(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS audit (
component TEXT,
ip_address TEXT,
user_id TEXT,
path TEXT,
request_type TEXT,
request_method TEXT,
request_details TEXT,
response_code TEXT,
duration REAL NOT NULL,
delete_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
-462
View File
@@ -1,462 +0,0 @@
from __future__ import annotations
import datetime
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from enum import Enum
from sqlite3 import Row
from typing import Callable, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from loguru import logger
from pydantic import BaseModel
from lnbits.db import Connection, FilterModel, FromRowModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
class BaseWallet(BaseModel):
id: str
name: str
adminkey: str
inkey: str
balance_msat: int
class Wallet(BaseWallet):
user: str
currency: Optional[str]
deleted: bool
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property
def balance(self) -> int:
return self.balance_msat // 1000
@property
def withdrawable_balance(self) -> int:
from .services import fee_reserve
return self.balance_msat - fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
async def get_payment(self, payment_hash: str) -> Optional[Payment]:
from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash)
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
class UserConfig(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider
class Account(FromRowModel):
id: str
is_super_user: Optional[bool] = False
is_admin: Optional[bool] = False
username: Optional[str] = None
email: Optional[str] = None
balance_msat: Optional[int] = 0
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
last_payment: Optional[datetime.datetime] = None
class AccountFilters(FilterModel):
__search_fields__ = ["id", "email", "username"]
__sort_fields__ = [
"balance_msat",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
]
id: str
last_payment: Optional[datetime.datetime] = None
transaction_count: Optional[int] = None
wallet_count: Optional[int] = None
username: Optional[str] = None
email: Optional[str] = None
class User(BaseModel):
id: str
email: Optional[str] = None
username: Optional[str] = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
config: Optional[UserConfig] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class CreateUser(BaseModel):
email: Optional[str] = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
config: Optional[UserConfig] = None
class UpdateUserPassword(BaseModel):
user_id: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class Payment(FromRowModel):
checking_id: str
pending: bool
amount: int
fee: int
memo: Optional[str]
time: int
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[float]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
@classmethod
def from_row(cls, row: Row):
return cls(
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
pending=row["pending"],
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
time=row["time"],
expiry=row["expiry"],
wallet_id=row["wallet"],
webhook=row["webhook"],
webhook_status=row["webhook_status"],
)
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < time.time() if self.expiry else False
@property
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("internal_")
async def update_status(
self,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> None:
from .crud import update_payment_details
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
conn=conn,
)
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
self.pending = pending
await update_payment_status(self.checking_id, pending)
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable:
return PaymentPendingStatus()
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: "
f"expired {expiration_date}"
)
await self.delete(conn)
# wait at least 15 minutes before deleting failed outgoing payments
elif self.is_out and status.failed:
if self.time + 900 < int(time.time()):
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete(conn)
else:
logger.warning(
f"Tried to delete outgoing payment {self.checking_id}: "
"skipping because it's not old enough"
)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' "
f"{self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_wallet_payment
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
checking_id: str
amount: int
fee: int
memo: Optional[str]
time: datetime.datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime.datetime]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
date: datetime.datetime
income: int
spending: int
balance: int
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: float
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class DecodePayment(BaseModel):
data: str
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
class CreateTopup(BaseModel):
id: str
amount: int
class CreateLnurlAuth(BaseModel):
callback: str
class CreateWallet(BaseModel):
name: Optional[str] = None
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: str
class BalanceDelta(BaseModel):
lnbits_balance_msats: int
node_balance_msats: int
@property
def delta_msats(self):
return self.node_balance_msats - self.lnbits_balance_msats
class SimpleStatus(BaseModel):
success: bool
message: str
+97
View File
@@ -0,0 +1,97 @@
from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
from .misc import (
BalanceDelta,
Callback,
ConversionData,
CoreAppExtra,
DbVersion,
SimpleStatus,
)
from .payments import (
CreateInvoice,
CreatePayment,
DecodePayment,
PayInvoice,
Payment,
PaymentExtra,
PaymentFilters,
PaymentHistoryPoint,
PaymentState,
)
from .tinyurl import TinyURL
from .users import (
AccessTokenPayload,
Account,
AccountFilters,
AccountOverview,
CreateTopup,
CreateUser,
LoginUsernamePassword,
LoginUsr,
RegisterUser,
ResetUserPassword,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserExtra,
)
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [
# audit
"AuditEntry",
"AuditFilters",
# lnurl
"CreateLnurl",
"CreateLnurlAuth",
"PayLnurlWData",
# misc
"BalanceDelta",
"Callback",
"ConversionData",
"CoreAppExtra",
"DbVersion",
"SimpleStatus",
# payments
"CreateInvoice",
"CreatePayment",
"DecodePayment",
"PayInvoice",
"Payment",
"PaymentExtra",
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
# tinyurl
"TinyURL",
# users
"AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
"CreateTopup",
"CreateUser",
"RegisterUser",
"LoginUsernamePassword",
"LoginUsr",
"ResetUserPassword",
"UpdateSuperuserPassword",
"UpdateUser",
"UpdateUserPassword",
"UpdateUserPubkey",
"User",
"UserExtra",
# wallets
"BaseWallet",
"CreateWallet",
"KeyType",
"Wallet",
"WalletTypeInfo",
# webpush
"CreateWebPushSubscription",
"WebPushSubscription",
]
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.settings import settings
class AuditEntry(BaseModel):
component: Optional[str] = None
ip_address: Optional[str] = None
user_id: Optional[str] = None
path: Optional[str] = None
request_type: Optional[str] = None
request_method: Optional[str] = None
request_details: Optional[str] = None
response_code: Optional[str] = None
duration: float
delete_at: Optional[datetime] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data):
super().__init__(**data)
retention_days = max(0, settings.lnbits_audit_retention_days) or 365
self.delete_at = self.created_at + timedelta(days=retention_days)
class AuditFilters(FilterModel):
__search_fields__ = [
"ip_address",
"user_id",
"path",
"request_method",
"response_code",
"component",
]
__sort_fields__ = [
"created_at",
"duration",
]
ip_address: Optional[str] = None
user_id: Optional[str] = None
path: Optional[str] = None
request_method: Optional[str] = None
response_code: Optional[str] = None
component: Optional[str] = None
class AuditCountStat(BaseModel):
field: str
total: float
class AuditStats(BaseModel):
request_method: list[AuditCountStat] = []
response_code: list[AuditCountStat] = []
component: list[AuditCountStat] = []
long_duration: list[AuditCountStat] = []
@@ -1,19 +1,23 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import shutil
import sys
import zipfile
from pathlib import Path
from typing import Any, List, NamedTuple, Optional, Tuple
from urllib import request
from typing import Any, Optional
import httpx
from loguru import logger
from packaging import version
from pydantic import BaseModel
from lnbits.helpers import (
download_url,
file_hash,
version_parse,
)
from lnbits.settings import settings
@@ -23,7 +27,7 @@ class ExplicitRelease(BaseModel):
version: str
archive: str
hash: str
dependencies: List[str] = []
dependencies: list[str] = []
repo: Optional[str]
icon: Optional[str]
short_description: Optional[str]
@@ -48,9 +52,9 @@ class GitHubRelease(BaseModel):
class Manifest(BaseModel):
featured: List[str] = []
extensions: List["ExplicitRelease"] = []
repos: List["GitHubRelease"] = []
featured: list[str] = []
extensions: list[ExplicitRelease] = []
repos: list[GitHubRelease] = []
class GitHubRepoRelease(BaseModel):
@@ -81,6 +85,17 @@ class ExtensionConfig(BaseModel):
return True
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):
amount: Optional[int] = None
@@ -90,8 +105,8 @@ class ReleasePaymentInfo(BaseModel):
class PayToEnableInfo(BaseModel):
required: Optional[bool] = False
amount: Optional[int] = None
amount: int
required: bool = False
wallet: Optional[str] = None
@@ -101,6 +116,7 @@ class UserExtensionInfo(BaseModel):
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: Optional[UserExtensionInfo] = None
@@ -112,7 +128,7 @@ class UserExtension(BaseModel):
return self.extra.paid_to_enable is True
@classmethod
def from_row(cls, data: dict) -> "UserExtension":
def from_row(cls, data: dict) -> UserExtension:
ext = UserExtension(**data)
ext.extra = (
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
@@ -122,135 +138,12 @@ class UserExtension(BaseModel):
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
async def fetch_release_details(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
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(BaseModel):
code: str
is_valid: bool
is_admin_only: bool
name: Optional[str] = None
short_description: Optional[str] = None
tile: Optional[str] = None
contributors: Optional[List[str]] = None
hidden: bool = False
migration_module: Optional[str] = None
db_name: Optional[str] = None
upgrade_hash: Optional[str] = ""
@property
@@ -269,60 +162,17 @@ class Extension(NamedTuple):
return self.upgrade_hash != ""
@classmethod
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
return Extension(
code=ext_info.id,
is_valid=True,
is_admin_only=False, # todo: is admin only
name=ext_info.name,
upgrade_hash=ext_info.hash if ext_info.module_installed else "",
short_description=ext_info.short_description,
tile=ext_info.icon,
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
)
# All subdirectories in the current directory, not recursive.
class ExtensionManager:
def __init__(self) -> None:
p = Path(settings.lnbits_extensions_path, "extensions")
Path(p).mkdir(parents=True, exist_ok=True)
self._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
output: List[Extension] = []
for extension_folder in self._extension_folders:
extension_code = extension_folder.parts[-1]
try:
with open(extension_folder / "config.json") as json_file:
config = json.load(json_file)
is_valid = True
is_admin_only = extension_code in settings.lnbits_admin_extensions
except Exception:
config = {}
is_valid = False
is_admin_only = False
output.append(
Extension(
extension_code,
is_valid,
is_admin_only,
config.get("name"),
config.get("short_description"),
config.get("tile"),
config.get("contributors"),
config.get("hidden") or False,
config.get("migration_module"),
config.get("db_name"),
)
)
return output
class ExtensionRelease(BaseModel):
name: str
version: str
@@ -356,13 +206,27 @@ class ExtensionRelease(BaseModel):
if not self.pay_link:
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
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
def from_github_release(
cls, source_repo: str, r: "GitHubRepoRelease"
) -> "ExtensionRelease":
cls, source_repo: str, r: GitHubRepoRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=r.name,
description=r.name,
@@ -377,8 +241,8 @@ class ExtensionRelease(BaseModel):
@classmethod
def from_explicit_release(
cls, source_repo: str, e: "ExplicitRelease"
) -> "ExtensionRelease":
cls, source_repo: str, e: ExplicitRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=e.name,
version=e.version,
@@ -397,9 +261,9 @@ class ExtensionRelease(BaseModel):
)
@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:
github_releases = await fetch_github_releases(org, repo)
github_releases = await cls.fetch_github_releases(org, repo)
return [
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases
@@ -408,30 +272,61 @@ class ExtensionRelease(BaseModel):
logger.warning(e)
return []
@classmethod
async def fetch_github_releases(
cls, org: str, repo: str
) -> list[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> 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):
id: str
name: str
version: str
active: Optional[bool] = False
short_description: Optional[str] = None
icon: Optional[str] = None
dependencies: List[str] = []
is_admin_only: bool = False
stars: int = 0
featured = False
latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = []
pay_to_enable: Optional[PayToEnableInfo] = None
archive: Optional[str] = None
meta: Optional[ExtensionMeta] = None
@property
def hash(self) -> str:
if self.installed_release:
if self.installed_release.hash:
return self.installed_release.hash
if self.meta and self.meta.installed_release:
if self.meta.installed_release.hash:
return self.meta.installed_release.hash
m = hashlib.sha256()
m.update(f"{self.installed_release.archive}".encode())
m.update(f"{self.meta.installed_release.archive}".encode())
return m.hexdigest()
return "not-installed"
@@ -457,10 +352,6 @@ class InstallableExtension(BaseModel):
return f"lnbits.extensions.{self.id}"
return self.id
@property
def module_installed(self) -> bool:
return self.module_name in sys.modules
@property
def has_installed_version(self) -> bool:
if not self.ext_dir.is_dir():
@@ -469,15 +360,15 @@ class InstallableExtension(BaseModel):
@property
def installed_version(self) -> str:
if self.installed_release:
return self.installed_release.version
if self.meta and self.meta.installed_release:
return self.meta.installed_release.version
return ""
@property
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 self.pay_to_enable.required is True
return self.meta.pay_to_enable.required is True
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
@@ -485,12 +376,14 @@ class InstallableExtension(BaseModel):
if ext_zip_file.is_file():
os.remove(ext_zip_file)
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()
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()
@@ -500,7 +393,11 @@ class InstallableExtension(BaseModel):
raise AssertionError("Cannot fetch extension archive file") from exc
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
if ext_zip_file.is_file():
os.remove(ext_zip_file)
@@ -534,27 +431,18 @@ class InstallableExtension(BaseModel):
self.short_description = config_json.get("short_description")
if (
self.installed_release
and self.installed_release.is_github_release
self.meta
and self.meta.installed_release
and self.meta.installed_release.is_github_release
and config_json.get("tile")
):
self.icon = icon_to_github_url(
self.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.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
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)
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
def clean_extension_files(self):
# remove downloaded archive
@@ -569,87 +457,78 @@ class InstallableExtension(BaseModel):
def check_latest_version(self, release: Optional[ExtensionRelease]):
if not release:
return
if not self.latest_release:
self.latest_release = release
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
self.meta = meta
return
if version_parse(self.latest_release.version) < version_parse(release.version):
self.latest_release = release
if version_parse(self.meta.latest_release.version) < version_parse(
release.version
):
self.meta.latest_release = release
def find_existing_payment(
self, pay_link: Optional[str]
) -> Optional[ReleasePaymentInfo]:
if not pay_link:
if not pay_link or not self.meta or not self.meta.payments:
return None
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,
)
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
if not self.installed_release.pay_link:
return
if self.installed_release.payment_hash:
return
payment_info = self.find_existing_payment(self.installed_release.pay_link)
payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
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):
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
payment_info = ReleasePaymentInfo(
amount=self.installed_release.cost_sats,
pay_link=self.installed_release.pay_link,
payment_hash=self.installed_release.payment_hash,
amount=self.meta.installed_release.cost_sats,
pay_link=self.meta.installed_release.pay_link,
payment_hash=self.meta.installed_release.payment_hash,
)
self.payments = [
p for p in self.payments if p.pay_link != payment_info.pay_link
self.meta.payments = [
p for p in self.meta.payments if p.pay_link != payment_info.pay_link
]
self.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]
self.meta.payments.append(payment_info)
@classmethod
async def from_github_release(
cls, github_release: GitHubRelease
) -> Optional["InstallableExtension"]:
) -> Optional[InstallableExtension]:
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
)
source_repo = f"{github_release.organisation}/{github_release.repository}"
return InstallableExtension(
id=github_release.id,
name=config.name,
version=latest_release.tag_name,
short_description=config.short_description,
stars=int(repo.stargazers_count),
icon=icon_to_github_url(
source_repo,
config.tile,
),
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
meta=ExtensionMeta(
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
),
)
except Exception as e:
@@ -657,26 +536,62 @@ class InstallableExtension(BaseModel):
return None
@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(
id=e.id,
name=e.name,
archive=e.archive,
version=e.version,
short_description=e.short_description,
icon=e.icon,
dependencies=e.dependencies,
meta=meta,
)
@classmethod
def from_ext_dir(cls, ext_id: str) -> Optional[InstallableExtension]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
version = config_json.get("version", "0.0")
return InstallableExtension(
id=ext_id,
name=config_json.get("name", ext_id),
active=True,
version=version,
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
meta=ExtensionMeta(
installed_release=ExtensionRelease(
name=ext_id,
version=version,
archive=f"{conf_path}",
source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"),
)
),
)
except Exception as e:
logger.warning(e)
return None
@classmethod
async def get_installable_extensions(
cls,
) -> List["InstallableExtension"]:
extension_list: List[InstallableExtension] = []
extension_id_list: List[str] = []
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
extension_id_list: list[str] = []
for url in settings.lnbits_extensions_manifests:
try:
manifest = await fetch_manifest(url)
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
ext = await InstallableExtension.from_github_release(r)
@@ -685,11 +600,13 @@ class InstallableExtension(BaseModel):
existing_ext = next(
(ee for ee in extension_list if ee.id == r.id), None
)
if existing_ext:
existing_ext.check_latest_version(ext.latest_release)
if existing_ext and ext.meta:
existing_ext.check_latest_version(ext.meta.latest_release)
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_id_list += [ext.id]
@@ -703,7 +620,9 @@ class InstallableExtension(BaseModel):
continue
ext = InstallableExtension.from_explicit_release(e)
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_id_list += [e.id]
except Exception as e:
@@ -712,12 +631,12 @@ class InstallableExtension(BaseModel):
return extension_list
@classmethod
async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]:
extension_releases: List[ExtensionRelease] = []
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = []
for url in settings.lnbits_extensions_manifests:
try:
manifest = await fetch_manifest(url)
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
if r.id != ext_id:
continue
@@ -741,8 +660,8 @@ class InstallableExtension(BaseModel):
@classmethod
async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str
) -> Optional["ExtensionRelease"]:
all_releases: List[ExtensionRelease] = (
) -> Optional[ExtensionRelease]:
all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
selected_release = [
@@ -755,6 +674,37 @@ class InstallableExtension(BaseModel):
return selected_release[0] if len(selected_release) != 0 else None
@classmethod
async def fetch_github_repo_info(
cls, org: str, repository: str
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
class CreateExtension(BaseModel):
ext_id: str
@@ -771,30 +721,21 @@ class ExtensionDetailsRequest(BaseModel):
version: str
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
valid_extensions = [
extension for extension in ExtensionManager().extensions if extension.is_valid
]
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
async def 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 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")
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}"
+20
View File
@@ -0,0 +1,20 @@
from typing import Optional
from pydantic import BaseModel
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
class CreateLnurlAuth(BaseModel):
callback: str
class PayLnurlWData(BaseModel):
lnurl_w: str
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Callable
from pydantic import BaseModel
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class BalanceDelta(BaseModel):
lnbits_balance_msats: int
node_balance_msats: int
@property
def delta_msats(self):
return self.node_balance_msats - self.lnbits_balance_msats
class SimpleStatus(BaseModel):
success: bool
message: str
class DbVersion(BaseModel):
db: str
version: int
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from fastapi import Query
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class PaymentExtra(BaseModel):
comment: Optional[str] = None
success_action: Optional[str] = None
lnurl_response: Optional[str] = None
class PayInvoice(BaseModel):
payment_request: str
description: Optional[str] = None
max_sat: Optional[int] = None
extra: Optional[dict] = {}
class CreatePayment(BaseModel):
wallet_id: str
payment_hash: str
bolt11: str
amount_msat: int
memo: str
extra: Optional[dict] = {}
preimage: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(BaseModel):
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
bolt11: str
status: str = PaymentState.PENDING
memo: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
webhook_status: Optional[int] = None
preimage: Optional[str] = None
tag: Optional[str] = None
extension: Optional[str] = None
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
extra: dict = {}
@property
def pending(self) -> bool:
return self.status == PaymentState.PENDING.value
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
@property
def is_internal(self) -> bool:
return self.checking_id.startswith("internal_")
async def check_status(self) -> PaymentStatus:
if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
checking_id: str
amount: int
fee: int
memo: Optional[str]
time: datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
date: datetime
income: int
spending: int
balance: int
class DecodePayment(BaseModel):
data: str
filter_fields: Optional[list[str]] = []
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
@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
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: float
+200
View File
@@ -0,0 +1,200 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import Query
from passlib.context import CryptContext
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
from lnbits.settings import settings
from .wallets import Wallet
class UserExtra(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider
class Account(BaseModel):
id: str
username: Optional[str] = None
password_hash: Optional[str] = None
pubkey: Optional[str] = None
email: Optional[str] = None
extra: UserExtra = UserExtra()
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True)
def __init__(self, **data):
super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id)
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)
def validate_fields(self):
if self.username and not is_valid_username(self.username):
raise ValueError("Invalid username.")
if self.email and not is_valid_email_address(self.email):
raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid pubkey.")
user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
class AccountOverview(Account):
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
balance_msat: Optional[int] = 0
last_payment: Optional[datetime] = None
class AccountFilters(FilterModel):
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
__sort_fields__ = [
"balance_msat",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
]
email: Optional[str] = None
user: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
wallet_id: Optional[str] = None
class User(BaseModel):
id: str
created_at: datetime
updated_at: datetime
email: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
extra: UserExtra = UserExtra()
@property
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class RegisterUser(BaseModel):
email: Optional[str] = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class CreateUser(BaseModel):
id: Optional[str] = Query(default=None)
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=None, min_length=2, max_length=20)
password: Optional[str] = Query(default=None, min_length=8, max_length=50)
password_repeat: Optional[str] = Query(default=None, min_length=8, max_length=50)
pubkey: str = Query(default=None, max_length=64)
extensions: Optional[list[str]] = None
extra: Optional[UserExtra] = None
class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
extra: Optional[UserExtra] = None
class UpdateUserPassword(BaseModel):
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_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class AccessTokenPayload(BaseModel):
sub: str
usr: Optional[str] = None
email: Optional[str] = None
auth_time: Optional[int] = 0
class CreateTopup(BaseModel):
id: str
amount: int
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from ecdsa import SECP256k1, SigningKey
from pydantic import BaseModel, Field
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
class BaseWallet(BaseModel):
id: str
name: str
adminkey: str
inkey: str
balance_msat: int
class Wallet(BaseModel):
id: str
user: str
name: str
adminkey: str
inkey: str
deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: Optional[str] = None
balance_msat: int = Field(default=0, no_database=True)
@property
def balance(self) -> int:
return int(self.balance_msat // 1000)
@property
def withdrawable_balance(self) -> int:
return self.balance_msat - settings.fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
class CreateWallet(BaseModel):
name: Optional[str] = None
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
+15
View File
@@ -0,0 +1,15 @@
from datetime import datetime
from pydantic import BaseModel
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: datetime
-858
View File
@@ -1,858 +0,0 @@
import asyncio
import datetime
import json
import time
from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict
from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from loguru import logger
from passlib.context import CryptContext
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
check_user_extension_access,
require_admin_key,
)
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import (
EditableSettings,
SuperSettings,
readonly_variables,
send_admin_user_to_saas,
settings,
)
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source, set_funding_source
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
)
from .crud import (
check_internal,
check_internal_pending,
create_account,
create_admin_settings,
create_payment,
create_wallet,
delete_wallet_payment,
get_account,
get_account_by_email,
get_account_by_username,
get_payments,
get_standalone_payment,
get_super_settings,
get_total_balance,
get_wallet,
get_wallet_payment,
update_admin_settings,
update_payment_details,
update_payment_status,
update_super_user,
update_user_extension,
)
from .helpers import to_valid_user_id
from .models import BalanceDelta, Payment, User, UserConfig, Wallet
class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class InvoiceError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
async def calculate_fiat_amounts(
amount: float,
wallet_id: str,
currency: Optional[str] = None,
extra: Optional[Dict] = None,
conn: Optional[Connection] = None,
) -> Tuple[int, Optional[Dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
extra = extra or {}
if currency != wallet_currency:
extra["fiat_currency"] = currency
extra["fiat_amount"] = round(amount, ndigits=3)
extra["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
if wallet_currency:
if wallet_currency == currency:
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
extra = extra or {}
extra["wallet_fiat_currency"] = wallet_currency
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
)
return amount_sat, extra
async def create_invoice(
*,
wallet_id: str,
amount: float,
currency: Optional[str] = "sat",
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Tuple[str, str]:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts(
amount, wallet_id, currency=currency, extra=extra, conn=conn
)
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceError(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
)
(
ok,
checking_id,
payment_request,
error_message,
) = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if not ok or not payment_request or not checking_id:
raise InvoiceError(
error_message or "unexpected backend error.", status="pending"
)
invoice = bolt11_decode(payment_request)
amount_msat = 1000 * amount_sat
await create_payment(
wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_msat,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
conn=conn,
)
return invoice.payment_hash, payment_request
async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[Dict] = None,
description: str = "",
conn: Optional[Connection] = None,
) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve
fee. We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend. If the payment is
successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process
will regularly check for the payment.
"""
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise PaymentError("Amountless invoices not supported.", status="failed")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise PaymentError("Amount in invoice is too high.", status="failed")
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
async with db.reuse_conn(conn) if conn else db.connect() as conn:
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
_, extra = await calculate_fiat_amounts(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
)
# put all parameters that don't change here
class PaymentKwargs(TypedDict):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
expiry: Optional[datetime.datetime]
extra: Optional[Dict]
payment_kwargs: PaymentKwargs = PaymentKwargs(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
# we check if an internal invoice exists that has already been paid
# (not pending anymore)
if not await check_internal_pending(invoice.payment_hash, conn=conn):
raise PaymentError("Internal invoice already paid.", status="failed")
# check_internal() returns the checking_id of the invoice we're waiting for
# (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_checking_id, incoming=True, conn=conn
)
assert internal_invoice is not None
if (
internal_invoice.amount != invoice.amount_msat
or internal_invoice.bolt11 != payment_request.lower()
):
raise PaymentError("Invalid invoice.", status="failed")
logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet
new_payment = await create_payment(
checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat),
pending=False,
conn=conn,
**payment_kwargs,
)
else:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=False
)
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment.", status="failed") from exc
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched"
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
if extra and "tag" in extra:
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, extra["tag"])
if not status.success:
raise PaymentError(status.message)
if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id, pending=False, conn=conn
)
await send_payment_notification(wallet, new_payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
await internal_invoice_queue.put(internal_checking_id)
else:
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice
funding_source = get_funding_source()
payment: PaymentResponse = await funding_source.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got:"
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
logger.debug(f"backend: pay_invoice response {payment}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
pending=payment.ok is not True,
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
),
preimage=payment.preimage,
new_checking_id=payment.checking_id,
conn=conn,
)
wallet = await get_wallet(wallet_id, conn=conn)
updated = await get_wallet_payment(
wallet_id, payment.checking_id, conn=conn
)
if wallet and updated:
await send_payment_notification(wallet, updated)
logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed
logger.warning("backend sent payment failure")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
raise PaymentError(
f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {temp_id}"
)
# credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat:
new_payment = await create_payment(
wallet_id=settings.lnbits_service_fee_wallet,
fee=0,
amount=abs(service_fee_msat),
memo="Service fee",
checking_id="service_fee" + temp_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
pending=False,
)
return invoice.payment_hash
def _check_wallet_balance(
wallet: Wallet,
fee_reserve_total_msat: int,
internal_checking_id: Optional[str] = None,
):
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
raise PaymentError("Insufficient balance.", status="failed")
async def check_wallet_limits(wallet_id, conn, amount_msat):
await check_time_limit_between_transactions(conn, wallet_id)
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
async def check_time_limit_between_transactions(conn, wallet_id):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
total = 0
for pay in payments:
total += pay.amount
total = total - amount_msat
if limit * 1000 + total < 0:
raise ValueError(
"Daily withdrawal limit of "
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
+ " sats reached."
)
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[Dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist"
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentPendingStatus()
if not payment.pending:
# note: before, we still checked the status of the payment again
return PaymentSuccessStatus(fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status()
return status
# WARN: this same value must be used for balance check and passed to
# funding_source.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
if internal:
return 0
reserve_min = settings.lnbits_reserve_fee_min
reserve_percent = settings.lnbits_reserve_fee_percent
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
def service_fee(amount_msat: int, internal: bool = False) -> int:
service_fee_percent = settings.lnbits_service_fee
fee_max = settings.lnbits_service_fee_max * 1000
if settings.lnbits_service_fee_wallet:
if internal and settings.lnbits_service_fee_ignore_internal:
return 0
fee_percentage = int(amount_msat / 100 * service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
else:
return 0
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocket_updater(
wallet.inkey,
json.dumps(
{
"wallet_balance": wallet.balance,
"payment": payment.dict(),
}
),
)
await websocket_updater(
payment.payment_hash, json.dumps({"pending": payment.pending})
)
async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
)
async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(checking_id=checking_id, pending=False, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(checking_id)
async def check_admin_settings():
if settings.super_user:
settings.super_user = to_valid_user_id(settings.super_user).hex
if settings.lnbits_admin_ui:
settings_db = await get_super_settings()
if not settings_db:
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.config and account.config.provider == "env":
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
continue
try:
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
if "super_user" in sets_dict:
settings.super_user = sets_dict["super_user"]
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
account = None
if super_user:
account = await get_account(super_user)
if not account:
account = await create_account(
user_id=super_user, user_config=UserConfig(provider="env")
)
if not account.wallets or len(account.wallets) == 0:
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
async def create_user_account(
user_id: Optional[str] = None,
email: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
wallet_name: Optional[str] = None,
user_config: Optional[UserConfig] = None,
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
if username and await get_account_by_username(username):
raise ValueError("Username already exists.")
if email and await get_account_by_email(email):
raise ValueError("Email already exists.")
if user_id:
user_uuid4 = UUID(hex=user_id, version=4)
assert user_uuid4.hex == user_id, "User ID is not valid UUID4 hex string"
else:
user_id = uuid4().hex
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password = pwd_context.hash(password) if password else None
account = await create_account(user_id, username, email, password, user_config)
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
account.wallets = [wallet]
for ext_id in settings.lnbits_user_default_extensions:
await update_user_extension(user_id=account.id, extension=ext_id, active=True)
return account
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id, data):
return await websocket_manager.send_data(f"{data}", item_id)
async def switch_to_voidwallet() -> None:
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
set_funding_source("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> BalanceDelta:
funding_source = get_funding_source()
status = await funding_source.status()
lnbits_balance = await get_total_balance()
return BalanceDelta(
lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat,
)
+65
View File
@@ -0,0 +1,65 @@
from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
)
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .payments import (
calculate_fiat_amounts,
check_transaction_status,
check_wallet_limits,
create_invoice,
fee_reserve,
fee_reserve_total,
pay_invoice,
send_payment_notification,
service_fee,
update_pending_payments,
update_wallet_balance,
)
from .settings import (
check_webpush_settings,
update_cached_settings,
)
from .users import (
check_admin_settings,
create_user_account,
create_user_account_no_ckeck,
init_admin_settings,
update_user_account,
update_user_extensions,
)
from .websockets import websocket_manager, websocket_updater
__all__ = [
# funding source
"get_balance_delta",
"switch_to_voidwallet",
# lnurl
"redeem_lnurl_withdraw",
"perform_lnurlauth",
# payments
"calculate_fiat_amounts",
"check_transaction_status",
"check_wallet_limits",
"create_invoice",
"fee_reserve",
"fee_reserve_total",
"pay_invoice",
"send_payment_notification",
"service_fee",
"update_pending_payments",
"update_wallet_balance",
# settings
"check_webpush_settings",
"update_cached_settings",
# users
"check_admin_settings",
"create_user_account",
"create_user_account_no_ckeck",
"init_admin_settings",
"update_user_account",
"update_user_extensions",
# websockets
"websocket_manager",
"websocket_updater",
]
+141
View File
@@ -0,0 +1,141 @@
import asyncio
import importlib
from typing import Optional
from loguru import logger
from lnbits.core import core_app_extra
from lnbits.core.crud import (
create_installed_extension,
delete_installed_extension,
get_db_version,
get_installed_extension,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
get_installed_extensions,
update_installed_extension,
)
from lnbits.core.helpers import migrate_extension_database
from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_id = ext_info.id
extension = Extension.from_installable_ext(ext_info)
installed_ext = await get_installed_extension(ext_id)
if installed_ext and installed_ext.meta:
ext_info.meta = ext_info.meta or ExtensionMeta()
ext_info.meta.payments = installed_ext.meta.payments
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)
# if the extensions does not exist in the installed extensions table, create it
# if it does exist, it will be activated later in the code
if not installed_ext:
await create_installed_extension(ext_info)
else:
await update_installed_extension(ext_info)
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.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# 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
async def get_valid_extensions(
include_deactivated: Optional[bool] = True,
) -> list[Extension]:
installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
async def get_valid_extension(
ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
ext = await get_installed_extension(ext_id)
if not ext:
return None
if include_deactivated:
return Extension.from_installable_ext(ext)
if settings.lnbits_extensions_deactivate_all:
return None
return Extension.from_installable_ext(ext)
+23
View File
@@ -0,0 +1,23 @@
from lnbits.settings import settings
from lnbits.wallets import get_funding_source, set_funding_source
from ..crud import get_total_balance
from ..models import BalanceDelta
async def switch_to_voidwallet() -> None:
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
set_funding_source("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> BalanceDelta:
funding_source = get_funding_source()
status = await funding_source.status()
lnbits_balance = await get_total_balance()
return BalanceDelta(
lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat,
)
+155
View File
@@ -0,0 +1,155 @@
import asyncio
import json
from io import BytesIO
from typing import Optional
from urllib.parse import parse_qs, urlparse
import httpx
from fastapi import Depends
from loguru import logger
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
)
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import settings
from .payments import create_invoice
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist"
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
+574
View File
@@ -0,0 +1,574 @@
import json
import time
from typing import Optional
from bolt11 import decode as bolt11_decode
from bolt11.types import Bolt11
from loguru import logger
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
)
from ..crud import (
check_internal,
create_payment,
get_payments,
get_standalone_payment,
get_wallet,
get_wallet_payment,
is_internal_status_success,
update_payment,
)
from ..models import (
CreatePayment,
Payment,
PaymentState,
Wallet,
)
from .websockets import websocket_manager
async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
description: str = "",
tag: str = "",
conn: Optional[Connection] = None,
) -> Payment:
invoice = _validate_payment_request(payment_request, max_sat)
assert invoice.amount_msat
async with db.reuse_conn(conn) if conn else db.connect() as conn:
amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, conn)
if await is_internal_status_success(invoice.payment_hash, conn):
raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=-amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
payment = await _pay_invoice(wallet, create_payment_model, conn)
await _credit_service_fee_wallet(payment, conn)
return payment
async def create_invoice(
*,
wallet_id: str,
amount: float,
currency: Optional[str] = "sat",
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Payment:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts(
amount, user_wallet, currency, extra
)
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceError(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
)
(
ok,
checking_id,
payment_request,
error_message,
) = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if not ok or not payment_request or not checking_id:
raise InvoiceError(
error_message or "unexpected backend error.", status="pending"
)
invoice = bolt11_decode(payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
)
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
return payment
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)
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
return settings.fee_reserve(amount_msat, internal)
def service_fee(amount_msat: int, internal: bool = False) -> int:
amount_msat = abs(amount_msat)
service_fee_percent = settings.lnbits_service_fee
fee_max = settings.lnbits_service_fee_max * 1000
if settings.lnbits_service_fee_wallet:
if internal and settings.lnbits_service_fee_ignore_internal:
return 0
fee_percentage = int(amount_msat / 100 * service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
else:
return 0
async def update_wallet_balance(wallet_id: str, amount: int):
async with db.connect() as conn:
payment = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
conn=conn,
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
async def send_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send_data(payment.json(), wallet.inkey)
# TODO: figure out why we send the balance with the payment here.
# cleaner would be to have a separate message for the balance
# and send it with the id of the wallet so wallets can subscribe to it
await websocket_manager.send_data(
json.dumps(
{
"wallet_balance": wallet.balance,
# use pydantic json serialization to get the correct datetime format
"payment": json.loads(payment.json()),
},
),
wallet.inkey,
)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending}), payment.payment_hash
)
async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
await check_time_limit_between_transactions(wallet_id, conn)
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
async def check_time_limit_between_transactions(
wallet_id: str, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
async def check_wallet_daily_withdraw_limit(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit:
return
if limit < 0:
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
total = 0
for pay in payments:
total += pay.amount
total = total - amount_msat
if limit * 1000 + total < 0:
raise ValueError(
"Daily withdrawal limit of "
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
+ " sats reached."
)
async def calculate_fiat_amounts(
amount: float,
wallet: Wallet,
currency: Optional[str] = None,
extra: Optional[dict] = None,
) -> tuple[int, dict]:
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
fiat_amounts: dict = extra or {}
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
if currency != wallet_currency:
fiat_amounts["fiat_currency"] = currency
fiat_amounts["fiat_amount"] = round(amount, ndigits=3)
fiat_amounts["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
if wallet_currency:
if wallet_currency == currency:
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
fiat_amounts["wallet_fiat_currency"] = wallet_currency
fiat_amounts["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
fiat_amounts["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
)
return amount_sat, fiat_amounts
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentPendingStatus()
if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee)
return await payment.check_status()
async def _pay_invoice(wallet, create_payment_model, conn):
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
return payment
async def _pay_internal_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Optional[Payment]:
"""
Pay an internal payment.
returns None if the payment is not internal.
"""
# check_internal() returns the payment of the invoice we're waiting for
# (pending only)
internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn
)
if not internal_payment:
return None
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn
)
if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed")
amount_msat = create_payment_model.amount_msat
if (
internal_invoice.amount != abs(amount_msat)
or internal_invoice.bolt11 != create_payment_model.bolt11.lower()
):
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
create_payment_model.fee = abs(fee_reserve_total_msat)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError("Insufficient balance.", status="failed")
internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment(
checking_id=internal_id,
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
internal_payment.status = PaymentState.SUCCESS
await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}")
await send_payment_notification(wallet, payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_payment.checking_id)
return payment
async def _pay_external_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Payment:
checking_id = create_payment_model.payment_hash
amount_msat = create_payment_model.amount_msat
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(checking_id, conn=conn)
if old_payment:
return await _verify_external_payment(old_payment, conn)
create_payment_model.fee = -abs(fee_reserve_total_msat)
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
funding_source = get_funding_source()
logger.debug(f"fundingsource: sending payment {checking_id}")
payment_response: PaymentResponse = await funding_source.pay_invoice(
create_payment_model.bolt11, fee_reserve_msat
)
logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
if payment_response.checking_id and payment_response.checking_id != checking_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {checking_id} got:"
f" {payment_response.checking_id})"
)
if payment_response.checking_id and payment_response.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {checking_id}")
payment.status = (
PaymentState.SUCCESS
if payment_response.ok is True
else PaymentState.PENDING
)
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment_response.preimage
await update_payment(payment, payment_response.checking_id, conn=conn)
payment.checking_id = payment_response.checking_id
if payment.success:
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
elif payment_response.checking_id is None and payment_response.ok is False:
# payment failed
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
raise PaymentError(
f"Payment failed: {payment_response.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {checking_id}"
)
return payment
async def _verify_external_payment(
payment: Payment, conn: Optional[Connection] = None
) -> Payment:
# fail on pending payments
if payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if payment.success:
raise PaymentError("Payment already paid.", status="success")
# payment failed
status = await payment.check_status()
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
if status.success:
# payment was successful on the fundingsource
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
# status.pending fall through and try again
return payment
async def _check_wallet_for_payment(
wallet_id: str,
tag: str,
amount_msat: int,
conn: Optional[Connection] = None,
):
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, tag, conn=conn)
if not status.success:
raise PaymentError(status.message)
await check_wallet_limits(wallet_id, amount_msat, conn)
return wallet
def _validate_payment_request(
payment_request: str, max_sat: Optional[int] = None
) -> Bolt11:
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise PaymentError("Amountless invoices not supported.", status="failed")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise PaymentError("Amount in invoice is too high.", status="failed")
return invoice
async def _credit_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(service_fee_msat),
memo="Service fee",
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
+50
View File
@@ -0,0 +1,50 @@
from cryptography.hazmat.primitives import serialization
from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.settings import (
EditableSettings,
readonly_variables,
settings,
)
from ..crud import update_admin_settings
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
if settings.lnbits_admin_ui:
await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
continue
try:
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
if "super_user" in sets_dict:
settings.super_user = sets_dict["super_user"]
+189
View File
@@ -0,0 +1,189 @@
from pathlib import Path
from typing import Optional
from uuid import uuid4
from loguru import logger
from lnbits.core.models.extensions import UserExtension
from lnbits.settings import (
EditableSettings,
SuperSettings,
send_admin_user_to_saas,
settings,
)
from ..crud import (
create_account,
create_admin_settings,
create_user_extension,
create_wallet,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_super_settings,
get_user_extensions,
get_user_from_account,
update_account,
update_super_user,
update_user_extension,
)
from ..helpers import to_valid_user_id
from ..models import (
Account,
User,
UserExtra,
)
from .settings import update_cached_settings
async def create_user_account(
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
return await create_user_account_no_ckeck(account, wallet_name)
async def create_user_account_no_ckeck(
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if account:
account.validate_fields()
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email):
raise ValueError("Email already exists.")
if account.pubkey and await get_account_by_pubkey(account.pubkey):
raise ValueError("Pubkey already exists.")
if not account.id:
account.id = uuid4().hex
account = await create_account(account)
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
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_from_account(account)
assert user, "Cannot find user for account."
return user
async def update_user_account(account: Account) -> Account:
account.validate_fields()
existing_account = await get_account(account.id)
if not existing_account:
raise ValueError("User does not exist.")
account.password_hash = existing_account.password_hash
if existing_account.username and not account.username:
raise ValueError("Cannot remove username.")
if account.username:
existing_account = await get_account_by_username(account.username)
if existing_account and existing_account.id != account.id:
raise ValueError("Username already exists.")
elif existing_account.username:
raise ValueError("Cannot remove username.")
if account.email:
existing_account = await get_account_by_email(account.email)
if existing_account and existing_account.id != account.id:
raise ValueError("Email already exists.")
if account.pubkey:
existing_account = await get_account_by_pubkey(account.pubkey)
if existing_account and existing_account.id != account.id:
raise ValueError("Pubkey already exists.")
return await update_account(account)
async def update_user_extensions(user_id: str, extensions: list[str]):
user_extensions = await get_user_extensions(user_id)
for user_ext in user_extensions:
if user_ext.active:
if user_ext.extension not in extensions:
user_ext.active = False
await update_user_extension(user_ext)
else:
if user_ext.extension in extensions:
user_ext.active = True
await update_user_extension(user_ext)
user_extension_ids = [ue.extension for ue in user_extensions]
for ext in extensions:
if ext in user_extension_ids:
continue
user_extension = UserExtension(user=user_id, extension=ext, active=True)
await create_user_extension(user_extension)
async def check_admin_settings():
if settings.super_user:
settings.super_user = to_valid_user_id(settings.super_user).hex
if settings.lnbits_admin_ui:
settings_db = await get_super_settings()
if not settings_db:
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
account = None
if super_user:
account = await get_account(super_user)
if not account:
account_id = super_user or uuid4().hex
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
)
await create_account(account)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
+27
View File
@@ -0,0 +1,27 @@
from fastapi import WebSocket
from loguru import logger
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(data, item_id)
+32 -1
View File
@@ -5,11 +5,13 @@ import httpx
from loguru import logger
from lnbits.core.crud import (
create_audit_entry,
get_wallet,
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.models import Payment
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.models import AuditEntry, Payment
from lnbits.core.services import (
get_balance_delta,
send_payment_notification,
@@ -19,6 +21,7 @@ from lnbits.settings import get_funding_source, settings
from lnbits.tasks import send_push_notification
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
audit_queue: asyncio.Queue = asyncio.Queue()
async def killswitch_task():
@@ -157,3 +160,31 @@ async def send_payment_push_notification(payment: Payment):
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
await send_push_notification(subscription, title, body, url)
async def wait_for_audit_data():
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
while settings.lnbits_running:
data: AuditEntry = await audit_queue.get()
try:
await create_audit_entry(data)
except Exception as ex:
logger.warning(ex)
await asyncio.sleep(3)
async def purge_audit_data():
"""
Remove audit entries which have passed their retention period.
"""
while settings.lnbits_running:
try:
await delete_expired_audit_entries()
except Exception as ex:
logger.warning(ex)
# clean every hour
await asyncio.sleep(60 * 60)
+212
View File
@@ -0,0 +1,212 @@
<q-tab-panel name="audit">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Audit</h6>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Enable audit</q-item-label>
<q-item-label caption
>Record HTTP requests according with the specified
filters</q-item-label
>
</q-item-section>
</q-item>
</div>
<div class="col-md-6 col-sm-12">
<q-input
filled
v-model="formData.lnbits_audit_retention_days"
type="number"
label="Retention days"
hint="Number of days to keep the audit entry."
>
</q-input>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<div class="row">
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_request_body"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record Request Body</q-item-label>
<q-item-label caption
>Warning:
<ul>
<li>confidential data (like passwords) will be logged</li>
<li>the request body can have large size.</li>
</ul>
Use it with caution.
</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_ip_address"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record IP Address</q-item-label>
<q-item-label caption>Save the client IP address.</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_path_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record Path Parameters</q-item-label>
<q-item-label caption>Recommended. </q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_query_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>Record Query Parameters</q-item-label>
<q-item-label caption>Recommended.</q-item-label>
</q-item-section>
</q-item>
</div>
</div>
<q-separator class="q-mb-xl q-mt-sm"></q-separator>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Include HTTP Methods</p>
<q-select
filled
v-model="formData.lnbits_audit_http_methods"
multiple
hint="List of HTTP methods to be included. Empty lists means all."
label="HTTP Methods"
:options="['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']"
></q-select>
</div>
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Include HTTP Response Codes</p>
<q-input
filled
v-model="formAddIncludeResponseCode"
@keydown.enter="addIncludeResponseCode"
type="text"
label="HTTP Response code (regex)"
hint="List of HTTP codes to be included (regex match). Empty lists means all. Eg: 4.*, 5.*"
>
<q-btn @click="addIncludeResponseCode" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="code in formData.lnbits_audit_http_response_codes"
:key="code"
removable
@remove="removeIncludeResponseCode(code)"
color="primary"
text-color="white"
:label="code"
>
</q-chip>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Include Paths</p>
<q-input
filled
v-model="formAddIncludePath"
@keydown.enter="addIncludePath"
type="text"
label="HTTP Path (regex)"
hint="List of paths to be included (regex match). Empty list means all."
>
<q-btn @click="addIncludePath" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="path in formData.lnbits_audit_include_paths"
:key="path"
removable
@remove="removeIncludePath(path)"
color="primary"
text-color="white"
:label="path"
>
</q-chip>
</div>
<br />
</div>
<div class="col-md-6 col-sm-12">
<p>Exclude Paths</p>
<q-input
filled
v-model="formAddExcludePath"
@keydown.enter="addExcludePath"
type="text"
label="HTTP Path (regex)"
hint="List of paths to be excluded (regex match). Empty list means none."
>
<q-btn @click="addExcludePath" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="path in formData.lnbits_audit_exclude_paths"
:key="path"
removable
@remove="removeExcludePath(path)"
color="primary"
text-color="white"
:label="path"
>
</q-chip>
</div>
<br />
<br />
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,94 @@
<q-tab-panel name="extensions">
<q-card-section class="q-pa-none">
<div>
<h6 class="q-my-none">Extensions</h6>
<div class="row q-col-gutter-md">
<div class="col-12">
<p>Extension Sources</p>
<q-input
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="manifestUrl"></span
></q-chip>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>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"
></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"
></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>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,140 @@
<q-tab-panel name="notifications">
<q-card-section class="q-pa-none">
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_notifications_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notifications"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
<p
v-if="!formData.lnbits_notifications"
v-text="$t('notifications_disabled')"
></p>
<div v-if="formData.lnbits_notifications">
{% include "admin/_tab_security_notifications.html" %}
</div>
<br />
<div>
<p v-text="$t('notification_source')"></p>
<q-input
filled
v-model="formData.lnbits_status_manifest"
type="text"
:label="$t('notification_source_label')"
/>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p v-text="$t('killswitch')"></p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_killswitch_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_killswitch"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('killswitch_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_killswitch_interval"
type="number"
/>
</q-item-section>
</q-item>
<br />
<p v-text="$t('watchdog')"></p>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval"
type="number"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
+53 -142
View File
@@ -1,8 +1,20 @@
<q-tab-panel name="security">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Authentication</h6>
<h6 class="q-my-none">Server Management</h6>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<div class="col-md-6">
<p>Base URL</p>
<q-input
filled
v-model.number="formData.lnbits_baseurl"
label="Static/Base url for the server"
></q-input>
<br />
</div>
</div>
<h6 class="q-my-none q-mb-sm">Authentication</h6>
<div class="row q-col-gutter-sm">
<div class="col-12 col-sm-6">
<q-input
filled
v-model="formData.auth_token_expire_minutes"
@@ -12,7 +24,7 @@
>
</q-input>
</div>
<div class="col-md-6 col-sm-12 q-pr-sm">
<div class="col-12 col-sm-6">
<q-select
filled
v-model="formData.auth_allowed_methods"
@@ -24,6 +36,40 @@
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('nostr-auth-nip98')"
class="q-pl-xl"
>
<strong class="q-my-none q-mb-sm">Nostr Auth</strong>
<div class="row">
<div class="col-12">
<q-input
class="q-mb-sm"
filled
v-model="nostrAcceptedUrl"
@keydown.enter="addNostrUrl"
type="text"
label="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 class="col-12">
<q-chip
v-for="url in formData.nostr_absolute_request_urls"
:key="url"
removable
@remove="removeNostrUrl(url)"
color="primary"
text-color="white"
:label="url"
class="ellipsis"
></q-chip>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('google-auth')"
class="q-pl-xl"
@@ -173,7 +219,8 @@
@remove="removeBlockedIPs(blocked_ip)"
color="primary"
text-color="white"
v-text="blocked_ip"
:label="blocked_ip"
class="ellipsis"
></q-chip>
</div>
<br />
@@ -202,7 +249,8 @@
@remove="removeAllowedIPs(allowed_ip)"
color="primary"
text-color="white"
v-text="allowed_ip"
:label="allowed_ip"
class="ellipsis"
></q-chip>
</div>
<br />
@@ -263,142 +311,5 @@
</div>
</div>
</div>
<br />
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_notifications_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notifications"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
<p
v-if="!formData.lnbits_notifications"
v-text="$t('notifications_disabled')"
></p>
<div v-if="formData.lnbits_notifications">
{% include "admin/_tab_security_notifications.html" %}
</div>
<br />
<div>
<p v-text="$t('notification_source')"></p>
<q-input
filled
v-model="formData.lnbits_status_manifest"
type="text"
:label="$t('notification_source_label')"
/>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p v-text="$t('killswitch')"></p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_killswitch_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_killswitch"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('killswitch_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_killswitch_interval"
type="number"
/>
</q-item-section>
</q-item>
<br />
<p v-text="$t('watchdog')"></p>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval"
type="number"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -22,7 +22,7 @@
<q-table
dense
flat
:data="statusData.notifications"
:rows="statusData.notifications"
:columns="statusDataTable.columns"
:no-data-label="$t('no_notifications')"
>
+1 -107
View File
@@ -1,24 +1,7 @@
<q-tab-panel name="server">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">Server Management</h6>
<br />
<div>
<div class="row">
<div class="col">
<p>Server Info</p>
<ul>
<li
v-if="settings.lnbits_data_folder"
v-text="'SQlite: ' + settings.lnbits_data_folder"
></li>
<li
v-if="settings.lnbits_database_url"
v-text="'Postgres: ' + settings.lnbits_database_url"
></li>
</ul>
<br />
</div>
</div>
<h6 class="q-my-none">Currency Settings</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Allowed currencies</p>
@@ -105,95 +88,6 @@
<br />
</div>
</div>
<q-separator></q-separator>
<h6 class="q-my-none">Extensions</h6>
<div class="row q-col-gutter-md">
<div class="col-12">
<p>Extension Sources</p>
<q-input
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
><span v-text="manifestUrl"></span
></q-chip>
</div>
</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>
</div>
</q-card-section>
</q-tab-panel>
+1 -1
View File
@@ -1,4 +1,4 @@
<q-tab-panel name="theme">
<q-tab-panel name="site_customisation">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">UI Management</h6>
<br />
+6 -4
View File
@@ -3,7 +3,7 @@
<h6 class="q-my-none q-mb-sm">User Management</h6>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<div class="col-12 col-md-6 q-pr-sm">
<p>Admin Users</p>
<q-input
filled
@@ -23,13 +23,14 @@
@remove="removeAdminUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
<span v-text="user"></span>
</q-chip>
</div>
<br />
</div>
<div class="col-md-6 col-sm-12">
<div class="col-12 col-md-6">
<p>Allowed Users</p>
<q-input
filled
@@ -49,8 +50,9 @@
@remove="removeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
<span v-text="user" />
</q-chip>
</div>
<br />
+72 -23
View File
@@ -26,6 +26,7 @@
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
@@ -60,50 +61,98 @@
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<div class="q-pa-md">
<div class="q-gutter-y-md">
<q-tabs v-model="tab" active-color="primary" align="justify">
<q-splitter>
<template v-slot:before>
<q-tabs v-model="tab" vertical active-color="primary">
<q-tab
name="funding"
:label="$t('funding')"
icon="account_balance_wallet"
:label="$q.screen.gt.sm ? $t('funding') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('funding')"></span></q-tooltip
></q-tab>
<q-tab
name="security"
icon="security"
:label="$q.screen.gt.sm ? $t('security') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('security')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
:label="$t('users')"
icon="group"
:label="$q.screen.gt.sm ? $t('users') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('users')"></span></q-tooltip
></q-tab>
<q-tab
name="extensions"
icon="extension"
:label="$q.screen.gt.sm ? $t('extensions') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('extensions')"></span></q-tooltip
></q-tab>
<q-tab
name="server"
:label="$t('server')"
icon="price_change"
:label="$q.screen.gt.sm ? $t('payments') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('payments')"></span></q-tooltip
></q-tab>
<q-tab
name="security"
:label="$t('security')"
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span></q-tooltip
></q-tab>
<q-tab
name="theme"
:label="$t('theme')"
name="audit"
icon="playlist_add_check_circle"
:label="$q.screen.gt.sm ? $t('audit') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
icon="language"
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
</q-tabs>
</div>
</div>
</template>
<q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
include "admin/_tab_security.html" %} {% include
"admin/_tab_theme.html" %}
</q-tab-panels>
</q-form>
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
{% include "admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}
</q-tab-panels>
</q-form>
</template>
</q-splitter>
</q-card>
</div>
</div>
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md justify-center q-mb-xl">
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong>Components</strong>
<div style="width: 250px" class="q-pa-sm">
<canvas ref="componentUseChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong>To 5 Long Running Endpoints</strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas ref="longDurationChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong>HTTP Request Methods</strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas ref="requestMethodChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong>HTTP Response Codes</strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas ref="responseCodeChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="auditEntries"
:columns="auditTable.columns"
v-model:pagination="auditTable.pagination"
:filter="auditTable.search"
:loading="auditTable.loading"
@request="fetchAudit"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchAuditBy()"
@update:model-value="searchAuditBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchAuditBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-select
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchAuditBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'created_at'">
<q-btn
icon="description"
:disable="!props.row.request_details"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="showDetailsDialog(props.row)"
>
<q-tooltip>Request Details</q-tooltip>
</q-btn>
<span v-text="formatDate(props.row.created_at)"></span>
<q-tooltip v-if="props.row.delete_at">
<span
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
></span>
</q-tooltip>
</div>
<div
v-else-if="['user_id', 'request_details'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name])"> </span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
@click="searchAuditBy(col.name, props.row[col.name])"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
<q-dialog v-model="auditDetailsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>HTTP Request Details</strong>
<q-input
filled
dense
v-model.trim="auditDetailsDialog.data"
type="textarea"
rows="25"
></q-input>
<div class="row q-mt-lg">
<q-btn
@click="copyText(auditDetailsDialog.data)"
icon="copy_content"
color="grey"
flat
v-text="$t('copy')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/audit.js') }}"></script>
{% endblock %}
+106 -26
View File
@@ -5,10 +5,75 @@
:content-inset-level="0.5"
>
<q-card-section>
<strong>Node URL: </strong><em v-text="origin"></em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText(wallet.id)"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)"
></q-icon>
</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-expansion-item
group="api"
@@ -20,7 +85,7 @@
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -30,12 +95,13 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl {{ request.base_url }}api/v1/wallet -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>"</code
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
<i v-text="wallet.inkey"></i>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
@@ -46,7 +112,7 @@
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<code>{"X-Api-Key": "<i v-text="wallet.inkey"></i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
@@ -62,9 +128,10 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
@@ -77,9 +144,23 @@
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<code
><span class="text-light-green">POST</span> /api/v1/payments (reveal
admin keys
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon
>)</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.adminkey }}"}</code>
<code
>{"X-Api-Key": "<i
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
@@ -88,10 +169,10 @@
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": true,
"bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
application/json"</code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": true, "bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
@@ -109,18 +190,16 @@
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"invoice": &lt;string&gt;}</code>
<code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
@@ -146,9 +225,10 @@
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}api/v1/payments/&lt;payment_hash&gt; -H "X-Api-Key:
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key: <i v-text="wallet.inkey"></i>" -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
+104 -27
View File
@@ -26,7 +26,9 @@
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="user">
<div v-if="passwordData.show">
<div v-if="credentialsData.show">
<q-separator></q-separator>
<q-card-section>
<div class="row">
<div class="col">
@@ -36,19 +38,26 @@
</div>
<div class="col">
<q-img
v-if="user.config.picture"
v-if="user.extra.picture"
style="max-width: 100px"
:src="user.config.picture"
:src="user.extra.picture"
class="float-right"
></q-img>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-input
v-model="credentialsData.username"
:label="$t('username')"
filled
dense
:readonly="hasUsername"
class="q-mb-md"
></q-input>
<q-input
v-if="user.has_password"
v-model="passwordData.oldPassword"
v-model="credentialsData.oldPassword"
type="password"
autocomplete="off"
label="Old Password"
@@ -57,7 +66,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPassword"
v-model="credentialsData.newPassword"
type="password"
autocomplete="off"
:label="$t('password')"
@@ -66,7 +75,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPasswordRepeat"
v-model="credentialsData.newPasswordRepeat"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
@@ -75,24 +84,47 @@
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="updatePassword"
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
:disable="disableUpdatePassword()"
unelevated
color="primary"
class="float-right"
:label="$t('change_password')"
>
</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
@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')"
outline
unelevated
color="grey"
class="float-right"
></q-btn>
</q-card-section>
</div>
@@ -101,9 +133,9 @@
<div class="row">
<div class="col">
<q-img
v-if="user.config.picture"
v-if="user.extra.picture"
style="max-width: 100px"
:src="user.config.picture"
:src="user.extra.picture"
class="float-right"
></q-img>
</div>
@@ -137,6 +169,15 @@
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.pubkey"
:label="$t('pubkey')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.email"
:label="$t('email')"
@@ -195,9 +236,9 @@
</div>
</q-card-section>
<q-card-section v-if="user.config">
<q-card-section v-if="user.extra">
<q-input
v-model="user.config.first_name"
v-model="user.extra.first_name"
:label="$t('first_name')"
filled
dense
@@ -205,7 +246,7 @@
>
</q-input>
<q-input
v-model="user.config.last_name"
v-model="user.extra.last_name"
:label="$t('last_name')"
filled
dense
@@ -213,7 +254,7 @@
>
</q-input>
<q-input
v-model="user.config.provider"
v-model="user.extra.provider"
:label="$t('auth_provider')"
filled
dense
@@ -222,10 +263,9 @@
>
</q-input>
<q-input
v-model="user.config.picture"
v-model="user.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
@@ -236,11 +276,10 @@
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showChangePassword()"
:label="user.has_password ? $t('change_password'): $t('set_password')"
outline
unelevated
color="grey"
@click="showUpdateCredentials()"
:label="$t('update_credentials')"
filled
color="primary"
class="float-right"
></q-btn>
</q-card-section>
@@ -373,6 +412,27 @@
</q-btn>
</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="col-4">
<span v-text="$t('toggle_darkmode')"></span>
@@ -392,6 +452,23 @@
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('border_choices')"></span>
</div>
<div class="col-8">
<q-select
v-model="borderChoice"
:options="borderOptions"
label="Reactions"
@update:model-value="applyBorder"
>
<q-tooltip
><span v-text="$t('border_choices')"></span
></q-tooltip>
</q-select>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">Notifications</div>
<div class="col-8">
@@ -410,7 +487,7 @@
v-model="reactionChoice"
:options="reactionOptions"
label="Reactions"
@input="reactionChoiceFunc"
@update:model-value="reactionChoiceFunc"
>
<q-tooltip
><span v-text="$t('payment_reactions')"></span
+32 -36
View File
@@ -23,26 +23,14 @@
<div class="q-pa-xs">
<div class="q-gutter-y-md">
<q-tabs
v-model="tab"
@input="handleTabChanged"
:model-value="tab"
@update:model-value="handleTabChanged"
active-color="primary"
align="left"
>
<q-tab
name="installed"
:label="$t('installed')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="all"
:label="$t('all')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="featured"
:label="$t('featured')"
@update="val => tab = val.name"
></q-tab>
<q-tab name="installed" :label="$t('installed')"></q-tab>
<q-tab name="all" :label="$t('all')"></q-tab>
<q-tab name="featured" :label="$t('featured')"></q-tab>
<i
v-if="!g.user.admin && tab != 'installed'"
v-text="$t('only_admins_can_install')"
@@ -117,9 +105,8 @@
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
:label="extension.isActive ? $t('activated'): $t('deactivated') "
color="secondary"
style=""
v-model="extension.isActive"
@input="toggleExtension(extension)"
@update:model-value="toggleExtension(extension)"
><q-tooltip>
&nbsp;
<span
@@ -471,6 +458,7 @@
filled
dense
emit-value
map-options
v-model="release.wallet"
:options="g.user.walletOptions"
:label="$t('wallet_required')"
@@ -607,6 +595,7 @@
filled
dense
emit-value
map-options
v-model="selectedExtension.payToEnable.wallet"
:options="g.user.walletOptions"
label="Wallet *"
@@ -669,11 +658,9 @@
<a
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
>
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div v-else class="col">
@@ -721,6 +708,7 @@
dense
v-model="selectedExtension.payToEnable.paymentWallet"
emit-value
map-options
:options="g.user.walletOptions"
:label="$t('wallet_required')"
class="q-mt-sm"
@@ -796,7 +784,7 @@
swipeable
animated
v-model="slide"
:fullscreen.sync="fullscreen"
v-model:fullscreen="fullscreen"
thumbnails
infinite
:autoplay="autoplay"
@@ -898,7 +886,7 @@
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
window.app = Vue.createApp({
el: '#vue',
data: function () {
@@ -1029,12 +1017,16 @@
this.filteredExtensions = this.extensions.concat([])
this.handleTabChanged('installed')
this.tab = 'installed'
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Extension uninstalled!'
})
if (this.uninstallAndDropDb) {
this.showDropDb()
} else {
setTimeout(() => {
window.location.reload()
}, 300)
}
})
.catch(err => {
@@ -1059,17 +1051,20 @@
extension.installedRelease = null
extension.inProgress = false
extension.hasDatabaseTables = false
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Extension DB deleted!'
})
setTimeout(() => {
window.location.reload()
}, 300)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
extension.inProgress = false
})
},
toggleExtension: function (extension) {
toggleExtension(extension) {
const action = extension.isActive ? 'activate' : 'deactivate'
LNbits.api
.request(
@@ -1078,13 +1073,14 @@
this.g.user.wallets[0].adminkey
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: `Extension '${extension.id}' ${action}d!`
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
extension.isActive = false
extension.inProgress = false
})
},
@@ -1103,7 +1099,7 @@
this.g.user.wallets[0].adminkey
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Extension enabled!'
})
@@ -1124,7 +1120,7 @@
this.g.user.wallets[0].adminkey
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Extension disabled!'
})
@@ -1157,7 +1153,7 @@
}
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Payment info updated!'
})
@@ -1339,7 +1335,7 @@
ws.addEventListener('message', async ({data}) => {
const payment = JSON.parse(data)
if (payment.pending === false) {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Invoice Paid!'
})
@@ -1408,13 +1404,13 @@
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
const resp = JSON.parse(data)
if (resp.paid) {
this.$q.notify({
Quasar.Notify.create({
type: 'positive',
message: 'Invoice Paid!'
})
this.installExtension(this.selectedRelease)
} else {
this.$q.notify({
Quasar.Notify.create({
type: 'warning',
message: 'Invoice tracking lost!'
})
@@ -97,9 +97,9 @@
}
</style>
<script>
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
loginData: {
+102 -3
View File
@@ -33,7 +33,7 @@
color="primary"
@click="processing"
type="a"
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')"
class="full-width"
></q-btn>
@@ -184,6 +184,47 @@
</div>
</q-form>
</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 %}
<q-card-section
v-if="authAction === 'register' && authMethod === 'user-id-only'"
@@ -230,7 +271,28 @@
v-if="authAction === 'login' && authMethod === 'username-password'"
>
<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">
<q-btn
href="/api/v1/auth/google"
@@ -484,6 +546,32 @@
</a>
</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="col">
<a
@@ -519,7 +607,18 @@
></q-img>
</a>
</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>
+82 -42
View File
@@ -6,7 +6,7 @@
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
{% endblock %}
<!---->
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
{% block title %}{{ wallet_name }} - {{ SITE_TITLE }} {% endblock %}
<!---->
{% block page %}
<div class="row q-col-gutter-md">
@@ -36,9 +36,8 @@
<q-card-section>
<h3 class="q-my-none text-no-wrap">
<strong v-text="formattedBalance"></strong>
<small>{{LNBITS_DENOMINATION}}</small>
<small> {{LNBITS_DENOMINATION}}</small>
<lnbits-update-balance
v-if="'{{user.super_user}}' == 'True'"
:wallet_id="this.g.wallet.id"
flat
:callback="updateBalanceCallback"
@@ -102,11 +101,25 @@
</div>
</div>
</q-card>
<payment-list
:update="updatePayments"
:wallet="this.g.wallet"
:mobile-simple="mobileSimple"
/>
<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>
<payment-list
:update="updatePayments"
:wallet="this.g.wallet"
:mobile-simple="mobileSimple"
/>
</q-card-section>
</q-card>
</div>
{% if HIDE_API %}
<div class="col-12 col-md-4 q-gutter-y-md">
@@ -119,7 +132,7 @@
<q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
{{ SITE_TITLE }} Wallet:
<strong><em>{{wallet.name}}</em></strong>
<strong><em>{{wallet_name}}</em></strong>
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
@@ -154,11 +167,17 @@
<q-card>
<q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p>
<qrcode
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
></qrcode>
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
</q-card-section>
<span v-text="exportWalletQR"></span>
<q-card-actions class="flex-center q-pb-md">
<q-btn
outline
color="grey"
:label="$t('copy_wallet_url')"
@click="copyText(exportUrl)"
></q-btn>
</q-card-actions>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
@@ -175,7 +194,6 @@
v-model.trim="update.name"
label="Name"
dense
@update:model-value="(e) => console.log(e)"
/>
</div>
<q-btn
@@ -277,7 +295,11 @@
</div>
</div>
<q-dialog v-model="receive.show" position="top">
<q-dialog
v-model="receive.show"
position="top"
@hide="onReceiveDialogHide"
>
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
@@ -311,13 +333,11 @@
<q-input
ref="setAmount"
filled
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
inputmode="numeric"
dense
v-model.number="receive.data.amount"
: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]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
@@ -364,13 +384,28 @@
>
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
<lnbits-qrcode
:value="'lightning:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div class="text-center">
<h3 class="q-my-md">
<span v-text="formattedAmount"></span>
</h3>
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
<span v-text="formattedSatAmount"></span>
</h5>
<q-chip v-if="hasNfc" outline square color="positive">
<q-avatar
icon="nfc"
color="positive"
text-color="white"
></q-avatar>
NFC supported
</q-chip>
<span v-else class="text-caption text-grey">NFC not supported</span>
</div>
<div class="row q-mt-lg">
<q-btn
outline
@@ -621,8 +656,8 @@
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
@@ -645,8 +680,8 @@
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</div>
@@ -661,22 +696,28 @@
</div>
</q-card>
</q-dialog>
<q-tabs
<div
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
active-class="px-0"
indicator-color="transparent"
align="justify"
>
<q-tab
icon="file_download"
@click="showReceiveDialog"
:label="$t('receive')"
<q-tabs
active-class="px-0"
indicator-color="transparent"
align="justify"
>
</q-tab>
<q-tab
icon="file_download"
@click="showReceiveDialog"
:label="$t('receive')"
>
</q-tab>
<q-tab @click="showParseDialog" icon="file_upload" :label="$t('send')">
</q-tab>
<q-tab
@click="showParseDialog"
icon="file_upload"
:label="$t('send')"
>
</q-tab>
</q-tabs>
<q-btn
round
size="35px"
@@ -686,8 +727,7 @@
class="text-white bg-primary z-top vertical-bottom absolute-center absolute"
>
</q-btn>
</q-tabs>
</div>
<q-dialog v-model="disclaimerDialog.show" position="top">
<q-card class="q-pa-lg">
<h6
@@ -160,7 +160,7 @@
<q-table
dense
flat
:data="this.filteredChannels"
:rows="this.filteredChannels"
:filter="channels.filter"
no-data-label="No channels opened"
>
@@ -239,7 +239,7 @@
<q-table
dense
flat
:data="peers.data"
:rows="peers.data"
:filter="peers.filter"
no-data-label="No transactions made yet"
>
@@ -51,11 +51,11 @@
>
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
<qrcode-vue
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
></qrcode-vue>
</q-responsive>
</a>
<q-btn
@@ -94,9 +94,9 @@
<q-table
dense
flat
:data="paymentsTable.data"
:rows="paymentsTable.data"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
v-model:pagination="paymentsTable.pagination"
row-key="payment_hash"
no-data-label="No transactions made yet"
:filter="paymentsTable.filter"
@@ -138,11 +138,11 @@
>
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
<qrcode-vue
:value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
></qrcode-vue>
</q-responsive>
</a>
</div>
@@ -256,9 +256,9 @@
<q-table
dense
flat
:data="invoiceTable.data"
:rows="invoiceTable.data"
:columns="invoiceTable.columns"
:pagination.sync="invoiceTable.pagination"
v-model:pagination="invoiceTable.pagination"
no-data-label="No transactions made yet"
:filter="invoiceTable.filter"
@request="getInvoices"
+9 -10
View File
@@ -42,12 +42,8 @@
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
new Vue({
window.app = Vue.createApp({
el: '#vue',
config: {
globalProperties: {
@@ -55,7 +51,7 @@
msg: 'hello'
}
},
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
isSuperUser: false,
@@ -319,7 +315,7 @@
.confirmDialog('Do you really wanna disconnect this peer?')
.onOk(() => {
this.api('DELETE', `/peers/${id}`).then(response => {
this.$q.notify({
Quasar.Notify.create({
message: 'Disconnected',
icon: null
})
@@ -367,10 +363,13 @@
this.transactionDetailsDialog.data = details
console.log('details', details)
},
exportCSV: function () {},
shortenNodeId
shortenNodeId(nodeId) {
return nodeId
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
: '...'
}
}
})
</script>
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
{% endblock %}
+2 -5
View File
@@ -53,10 +53,7 @@ context %} {% block page %}
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
new Vue({
window.app = Vue.createApp({
el: '#vue',
config: {
globalProperties: {
@@ -64,7 +61,7 @@ context %} {% block page %}
msg: 'hello'
}
},
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
isSuperUser: false,
@@ -1,23 +0,0 @@
<q-dialog v-model="createUserDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<p>Create User</p>
<div class="row">
<div class="col-12">
<q-form @submit="createUser">
<lnbits-dynamic-fields
:options="createUserDialog.fields"
v-model="createUserDialog.data"
></lnbits-dynamic-fields>
<div class="row q-mt-lg">
<q-btn v-close-popup unelevated color="primary" type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</div>
</div>
</q-card>
</q-dialog>
@@ -1,22 +1,43 @@
<q-dialog v-model="createWalletDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<p>Create Wallet</p>
<q-dialog v-model="createWalletDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>Create Wallet</strong>
<div class="row">
<div class="col-12">
<q-form @submit="createWallet">
<lnbits-dynamic-fields
:options="createWalletDialog.fields"
v-model="createUserDialog.data"
></lnbits-dynamic-fields>
<div class="row q-mt-lg">
<q-btn v-close-popup unelevated color="primary" type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
<div class="row q-mt-lg">
<div class="col">
<q-input
v-model="createWalletDialog.data.name"
:label='$t("name_your_wallet")'
filled
dense
class="q-mb-md"
>
</q-input>
</div>
</q-form>
</div>
<div class="row q-mt-lg">
<div class="col">
<q-select
filled
dense
v-model="createWalletDialog.data.currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-close-popup
@click="createWallet()"
unelevated
color="primary"
type="submit"
>Create</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</div>
</q-card>
@@ -0,0 +1,187 @@
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
v-if="activeUser.data.id"
@click="updateUser()"
color="primary"
:label="$t('update_account')"
class="q-ml-md"
></q-btn>
<q-btn
v-else
@click="createUser()"
:label="$t('create_account')"
color="primary"
class="float-right"
></q-btn>
</div>
</div>
<q-card v-if="activeUser.show" class="q-pa-md">
<q-card-section>
<div class="text-h6">
<span v-if="activeUser.data.id" v-text="$t('update_account')"></span>
<span v-else v-text="$t('create_account')"></span>
</div>
</q-card-section>
<q-card-section>
<q-input
v-if="activeUser.data.id"
v-model="activeUser.data.id"
:label="$t('user_id')"
filled
dense
readonly
:type="activeUser.data.showUserId ? 'text': 'password'"
class="q-mb-md"
><q-btn
@click="activeUser.data.showUserId = !activeUser.data.showUserId"
dense
flat
:icon="activeUser.data.showUserId ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.username"
:label="$t('username')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-toggle
size="xs"
v-if="!activeUser.data.id"
color="secondary"
:label="$t('set_password')"
v-model="activeUser.setPassword"
>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password"
:type="activeUser.data.showPassword ? 'text': 'password'"
autocomplete="off"
:label="$t('password')"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-if="activeUser.setPassword"
v-model="activeUser.data.password_repeat"
:type="activeUser.data.showPassword ? 'text': 'password'"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
filled
dense
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
>
<q-btn
@click="activeUser.data.showPassword = !activeUser.data.showPassword"
dense
flat
:icon="activeUser.data.showPassword ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="activeUser.data.pubkey"
:label="$t('pubkey')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.email"
:label="$t('email')"
filled
dense
class="q-mb-md"
>
</q-input>
</q-card-section>
<q-card-section v-if="activeUser.data.extra">
<q-input
v-model="activeUser.data.extra.first_name"
:label="$t('first_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.last_name"
:label="$t('last_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.provider"
:label="$t('auth_provider')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-select
filled
dense
v-model="activeUser.data.extensions"
multiple
label="User extensions"
:options="g.extensions"
></q-select>
</q-card-section>
<q-card-section v-if="activeUser.data.id">
<q-btn
@click="resetPassword(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('reset_password')"
icon="refresh"
color="primary"
>
<q-tooltip>Generate and copy password reset url</q-tooltip>
</q-btn>
<q-btn
@click="deleteUser(activeUser.data.id)"
:disable="activeUser.data.is_super_user"
:label="$t('delete')"
icon="delete"
color="negative"
class="float-right"
>
<q-tooltip>Delete User</q-tooltip></q-btn
>
</q-card-section>
</q-card>
@@ -1,12 +1,38 @@
<q-dialog v-model="walletDialog.show">
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
<div v-if="paymentPage.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="paymentPage.show = false"
:label="$t('back')"
></q-btn>
</div>
</div>
<q-card class="q-pa-md">
<q-card-section>
<payment-list :wallet="paymentsWallet" />
</q-card-section>
</q-card>
</div>
<div v-else-if="activeWallet.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
class="q-ml-md"
></q-btn>
</div>
</div>
<q-card class="q-pa-md">
<h2 class="text-h6 q-mb-md">Wallets</h2>
<q-dialog v-model="paymentDialog.show">
<q-card class="q-pa-lg" style="width: 700px; max-width: 80vw">
<payment-list :wallet="activeWallet" />
</q-card>
</q-dialog>
<q-table :data="wallets" :columns="walletTable.columns">
<q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
@@ -22,6 +48,13 @@
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
:label="$t('topup')"
@click="showTopupDialog(props.row.id)"
color="secondary"
size="sm"
class="q-mr-md"
></q-btn>
<q-btn
round
icon="menu"
@@ -31,27 +64,14 @@
>
<q-tooltip>Show Payments</q-tooltip>
</q-btn>
<q-btn
v-if="!props.row.deleted"
round
icon="content_copy"
size="sm"
color="primary"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
</q-btn>
<lnbits-update-balance
v-if="!props.row.deleted"
:wallet_id="props.row.id"
:callback="topupCallback"
></lnbits-update-balance>
<q-btn
round
v-if="!props.row.deleted"
icon="vpn_key"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.adminkey)"
>
<q-tooltip>Copy Admin Key</q-tooltip>
@@ -62,45 +82,68 @@
icon="vpn_key"
size="sm"
color="secondary"
class="q-ml-xs"
@click="copyText(props.row.inkey)"
>
<q-tooltip>Copy Invoice Key</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Delete Wallet</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
icon="link"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyWalletLink(props.row.id)"
>
<q-tooltip>Copy Wallet Link</q-tooltip>
</q-btn>
<span v-text="props.row.name"></span>
<q-btn
round
v-if="props.row.deleted"
icon="toggle_off"
size="sm"
color="secondary"
class="q-ml-xs"
@click="undeleteUserWallet(props.row.user, props.row.id)"
>
<q-tooltip>Undelete Wallet</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Delete Wallet</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width v-text="props.row.name"></q-td>
<q-td auto-width>
<q-btn
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
</q-btn>
<span
v-text="props.row.id"
:class="props.row.deleted ? 'text-strike' : ''"
></span>
</q-td>
<q-td auto-width v-text="props.row.currency"></q-td>
<q-td auto-width v-text="formatSat(props.row.balance_msat)"></q-td>
<q-td auto-width v-text="props.row.deleted"></q-td>
</q-tr>
</template>
</q-table>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
</div>
+108 -71
View File
@@ -1,33 +1,30 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %} {% include "users/_walletDialog.html" %} {% include
"users/_topupDialog.html" %} {% include "users/_createUserDialog.html" %} {%
include "users/_createWalletDialog.html" %}
<h3 class="text-subtitle q-my-none" v-text="$t('users')"></h3>
%} {% block page %}{% include "users/_topupDialog.html" %}
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md" style="width: 300px">
<div style="width: 600px">
<canvas ref="chart1"></canvas>
<div class="col">
{% include "users/_manageWallet.html" %}
<div v-if="activeUser.show" class="row">
<div class="col-12 col-md-6">{%include "users/_manageUser.html" %}</div>
</div>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<q-btn :label="$t('topup')" @click="topupDialog.show = true">
<q-tooltip
>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip
>
</q-btn>
</div>
<div v-else-if="activeWallet.show">
{%include "users/_createWalletDialog.html" %}
</div>
<div v-else>
<q-btn
@click="showAccountPage()"
:label="$t('create_account')"
color="primary"
class="q-mb-md"
>
</q-btn>
<q-card class="q-pa-md">
<q-table
:data="users"
:row-key="usersTableRowKey"
row-key="id"
:rows="users"
:columns="usersTable.columns"
:pagination.sync="usersTable.pagination"
v-model:pagination="usersTable.pagination"
:no-data-label="$t('no_users')"
:filter="usersTable.search"
:loading="usersTable.loading"
@@ -36,78 +33,118 @@ include "users/_createWalletDialog.html" %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
v-text="col.label"
:key="col.name"
:props="props"
></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['user', 'username', 'email', 'pubkey', 'wallet_id'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchUserBy(col.name)"
dense
type="text"
filled
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchUserBy(col.name)"
class="cursor-pointer"
/>
</template>
</q-input>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td>
<q-btn
@click="showAccountPage(props.row.id)"
round
icon="edit"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>
<span v-text="$t('update_account')"></span>
</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-toggle
size="xs"
v-if="!props.row.is_super_user"
color="secondary"
v-model="props.row.is_admin"
@update:model-value="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
</q-toggle>
<q-btn
round
v-if="props.row.is_super_user"
icon="verified"
size="sm"
color="secondary"
class="q-ml-xs"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
icon="list"
size="sm"
color="secondary"
:label="props.row.wallet_count"
@click="fetchWallets(props.row.id)"
>
<q-tooltip>Show Wallets</q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
round
icon="content_copy"
size="sm"
color="primary"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy User ID</q-tooltip>
</q-btn>
<q-btn
round
v-if="!props.row.is_super_user"
icon="build"
size="sm"
:color="props.row.is_admin ? 'primary' : ''"
@click="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
</q-btn>
<q-btn
round
v-if="props.row.is_super_user"
icon="build"
size="sm"
color="positive"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
size="sm"
color="negative"
@click="deleteUser(props.row.id, props)"
>
<q-tooltip>Delete User</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.id)"></span>
</q-td>
<q-td
auto-width
v-text="formatSat(props.row.balance_msat)"
></q-td>
<q-td auto-width v-text="props.row.wallet_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.email"></q-td>
<q-td auto-width v-text="props.row.last_payment"></q-td>
<q-td v-text="props.row.username"></q-td>
<q-td v-text="props.row.email"></q-td>
<q-td>
<q-btn
v-if="props.row.pubkey"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row.pubkey)"
>
<q-tooltip>Copy Public Key</q-tooltip>
</q-btn>
<span v-text="shortify(props.row.pubkey)"></span>
</q-td>
<q-td v-text="formatSat(props.row.balance_msat)"></q-td>
<q-td v-text="props.row.transaction_count"></q-td>
<q-td v-text="formatDate(props.row.last_payment)"></q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</q-card>
</div>
</div>
</div>
+52 -21
View File
@@ -2,7 +2,8 @@ import hashlib
import json
from http import HTTPStatus
from io import BytesIO
from typing import Dict, List
from time import time
from typing import Any
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
@@ -12,8 +13,9 @@ from fastapi import (
Depends,
)
from fastapi.exceptions import HTTPException
from starlette.responses import StreamingResponse
from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user
from lnbits.core.models import (
BaseWallet,
ConversionData,
@@ -33,41 +35,64 @@ from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
allowed_currencies,
fiat_amount_as_satoshis,
get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
)
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import StatusResponse
from ..services import create_user_account, 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.get("/api/v1/health", status_code=HTTPStatus.OK)
async def health():
return
async def health() -> dict:
return {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
}
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
async def health_check(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> dict:
stat: dict[str, Any] = {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
}
user = await get_user(wallet.wallet.user)
if not user:
return stat
stat["version"] = settings.version
if not user.admin:
return stat
funding_source = get_funding_source()
stat["funding_source"] = funding_source.__class__.__name__
status: StatusResponse = await funding_source.status()
stat["funding_source_error"] = status.error_message
stat["funding_source_balance_msat"] = status.balance_msat
return stat
@api_router.get(
"/api/v1/wallets",
name="Wallets",
description="Get basic info for all of user's wallets.",
response_model=list[BaseWallet],
)
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
return [BaseWallet(**w.dict()) for w in user.wallets]
async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
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:
if not settings.new_accounts_allowed:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Account creation is disabled.",
)
account = await create_user_account(wallet_name=data.name)
return account.wallets[0]
user = await create_user_account(wallet_name=data.name)
return user.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
@@ -95,7 +120,7 @@ async def api_lnurlscan(
) from exc
# params is what will be returned to the client
params: Dict = {"domain": domain}
params: dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
@@ -144,7 +169,7 @@ async def api_lnurlscan(
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: Dict = parse_qs(parsed_callback.query)
qs: dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
@@ -200,8 +225,14 @@ async def api_perform_lnurlauth(
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")
async def api_list_currencies_available() -> List[str]:
async def api_list_currencies_available() -> list[str]:
return allowed_currencies()
+49
View File
@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends
from lnbits.core.crud.audit import (
get_audit_entries,
get_count_stats,
get_long_duration_stats,
)
from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditStats
from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, parse_filters
from lnbits.helpers import generate_filter_params_openapi
audit_router = APIRouter(
prefix="/audit/api/v1", dependencies=[Depends(check_admin)], tags=["Audit"]
)
@audit_router.get(
"",
name="Get audit entries",
summary="Get paginated list audit entries",
openapi_extra=generate_filter_params_openapi(AuditFilters),
)
async def api_get_audit(
filters: Filters = Depends(parse_filters(AuditFilters)),
) -> Page[AuditEntry]:
return await get_audit_entries(filters)
@audit_router.get(
"/stats",
name="Get audit entries",
summary="Get paginated list audit entries",
openapi_extra=generate_filter_params_openapi(AuditFilters),
)
async def api_get_audit_stats(
filters: Filters = Depends(parse_filters(AuditFilters)),
) -> AuditStats:
request_mothod_stats = await get_count_stats("request_method", filters)
response_code_stats = await get_count_stats("response_code", filters)
components_stats = await get_count_stats("component", filters)
long_duration_stats = await get_long_duration_stats(filters)
return AuditStats(
request_method=request_mothod_stats,
response_code=response_code_stats,
component=components_stats,
long_duration=long_duration_stats,
)
+261 -142
View File
@@ -1,19 +1,18 @@
import base64
import importlib
import json
from http import HTTPStatus
from time import time
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_sso.sso.base import OpenID, SSOBase
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.core.services import create_user_account
from lnbits.decorators import check_user_exists
from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.helpers import (
create_access_token,
decrypt_internal_message,
@@ -22,25 +21,30 @@ from lnbits.helpers import (
is_valid_username,
)
from lnbits.settings import AuthMethods, settings
from lnbits.utils.nostr import normalize_public_key, verify_event
from ..crud import (
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email,
get_user,
get_user_from_account,
update_account,
update_user_password,
verify_user_password,
)
from ..models import (
CreateUser,
AccessTokenPayload,
Account,
LoginUsernamePassword,
LoginUsr,
RegisterUser,
ResetUserPassword,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserConfig,
UserExtra,
)
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
@@ -55,41 +59,43 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
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:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
if not await verify_user_password(user.id, data.password):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(user.username, user.id)
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.post("/nostr", description="Login via Nostr")
async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
)
event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"])
if not account:
account = Account(
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")
async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
try:
user = await get_user(data.usr)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(user.username or "", user.id)
except HTTPException as exc:
raise exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"Login by 'User ID' not allowed.",
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(account.username, account.id, account.email)
@auth_router.get("/{provider}", description="SSO Provider")
@@ -99,7 +105,8 @@ async def login_with_sso_provider(
provider_sso = _new_sso(provider)
if not provider_sso:
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"
@@ -113,31 +120,22 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
HTTPStatus.UNAUTHORIZED,
f"Login by '{provider}' not allowed.",
)
try:
with provider_sso:
userinfo = await provider_sso.verify_and_process(request)
assert userinfo is not None
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
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
with provider_sso:
userinfo = await provider_sso.verify_and_process(request)
if not userinfo:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
@auth_router.post("/logout")
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("is_lnbits_user_authorized")
response.delete_cookie("is_access_token_expired")
@@ -147,58 +145,127 @@ async def logout() -> JSONResponse:
@auth_router.post("/register")
async def register(data: CreateUser) -> JSONResponse:
async def register(data: RegisterUser) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
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:
raise HTTPException(HTTP_400_BAD_REQUEST, "Passwords do not match.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Passwords do not match.")
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):
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):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
try:
user = await create_user_account(
email=data.email, username=data.username, password=data.password
)
return _auth_success_response(user.username)
account = Account(
id=uuid4().hex,
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
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user."
) from exc
@auth_router.put("/pubkey")
async def update_pubkey(
data: UpdateUserPubkey,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> 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_from_account(account)
@auth_router.put("/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]:
_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_from_account(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):
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:
return await update_user_password(data)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
reset_key = base64.b64decode(data.reset_key[10:]).decode()
reset_data_json = decrypt_internal_message(reset_key)
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
) from exc
raise ValueError("Invalid reset key.") from exc
assert reset_data_json, "Cannot process reset key."
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")
@@ -206,80 +273,83 @@ async def update(
data: UpdateUser, user: User = Depends(check_user_exists)
) -> Optional[User]:
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):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
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(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user."
) from exc
HTTPStatus.BAD_REQUEST,
"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_from_account(account)
@auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
try:
await update_account(
user_id=settings.super_user,
username=data.username,
user_config=UserConfig(provider="lnbits"),
)
super_user = UpdateUserPassword(
user_id=settings.super_user,
password=data.password,
password_repeat=data.password_repeat,
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
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
account = await get_account(settings.super_user)
if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
account.username = data.username
account.extra = account.extra or UserExtra()
account.extra.provider = "lnbits"
account.hash_password(data.password)
await update_account(account)
settings.first_install = False
return _auth_success_response(account.username, account.id, account.email)
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
email = userinfo.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"
user_config = UserConfig(**dict(userinfo))
user_config.email_verified = True
account = await get_account_by_email(email)
if verified_user_id:
if account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Email already used.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
account = await get_account(verified_user_id)
if not account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Cannot verify user email.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
redirect_path = "/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:
if not settings.new_accounts_allowed:
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
user = await create_user_account(email=email, user_config=user_config)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
account = Account(
id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
)
await create_user_account(account)
return _auth_redirect_response(redirect_path, email)
@@ -288,9 +358,10 @@ def _auth_success_response(
user_id: Optional[str] = None,
email: Optional[str] = None,
) -> JSONResponse:
access_token = create_access_token(
data={"sub": username or "", "usr": user_id, "email": email}
payload = AccessTokenPayload(
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.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
@@ -300,7 +371,8 @@ def _auth_success_response(
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.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
@@ -338,7 +410,7 @@ def _new_sso(provider: str) -> Optional[SSOBase]:
def _find_auth_provider_class(provider: str) -> Callable:
sso_modules = ["lnbits.core.sso", "fastapi_sso.sso"]
sso_modules = ["lnbits.core.models.sso", "fastapi_sso.sso"]
for module in sso_modules:
try:
provider_module = importlib.import_module(f"{module}.{provider}")
@@ -349,3 +421,50 @@ def _find_auth_provider_class(provider: str) -> Callable:
pass
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!",
)
+200 -201
View File
@@ -1,9 +1,6 @@
import sys
import traceback
from http import HTTPStatus
from typing import (
List,
Optional,
)
from bolt11 import decode as bolt11_decode
from fastapi import (
@@ -13,49 +10,47 @@ from fastapi import (
)
from loguru import logger
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import (
migrate_extension_database,
stop_extension_background_work,
)
from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.models import (
SimpleStatus,
User,
)
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import (
check_access_token,
check_admin,
check_user_exists,
)
from lnbits.extension_manager import (
from lnbits.core.models.extensions import (
CreateExtension,
Extension,
ExtensionConfig,
ExtensionMeta,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtension,
UserExtensionInfo,
fetch_github_release_config,
fetch_release_details,
fetch_release_payment_info,
get_valid_extensions,
)
from lnbits.settings import settings
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.services.extensions import (
activate_extension,
deactivate_extension,
get_valid_extension,
get_valid_extensions,
install_extension,
uninstall_extension,
)
from lnbits.decorators import (
check_admin,
check_user_exists,
)
from ..crud import (
add_installed_extension,
create_user_extension,
delete_dbversion,
delete_installed_extension,
drop_extension_db,
get_dbversions,
get_db_version,
get_installed_extension,
get_installed_extensions,
get_user_extension,
update_extension_pay_to_enable,
update_installed_extension_state,
update_installed_extension,
update_user_extension,
update_user_extension_extra,
)
extension_router = APIRouter(
@@ -64,12 +59,8 @@ extension_router = APIRouter(
)
@extension_router.post("")
async def api_install_extension(
data: CreateExtension,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
@extension_router.post("", dependencies=[Depends(check_admin)])
async def api_install_extension(data: CreateExtension):
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
@@ -84,78 +75,73 @@ async def api_install_extension(
)
release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta(installed_release=release)
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:
installed_ext = await get_installed_extension(data.ext_id)
ext_info.payments = installed_ext.payments if installed_ext else []
extension = await install_extension(ext_info)
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:
logger.warning(exc)
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
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(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(
f"Failed to install extension {ext_info.id} "
f"({ext_info.installed_version})."
),
detail=detail,
) 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", dependencies=[Depends(check_user_exists)])
@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)
try:
all_releases = await InstallableExtension.get_extension_releases(ext_id)
release = next(
(r for r in all_releases if r.details_link == details_link), None
)
assert release, "Details not found for release"
release_details = await fetch_release_details(details_link)
assert release_details, "Cannot fetch details for release"
release_details["icon"] = release.icon
release_details["repo"] = release.repo
return release_details
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
release = next((r for r in all_releases if r.details_link == details_link), None)
if not release:
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
f"Failed to get details for extension {ext_id}.",
) from exc
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")
@@ -164,29 +150,28 @@ async def api_update_pay_to_enable(
data: PayToEnableInfo,
user: User = Depends(check_admin),
) -> SimpleStatus:
try:
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)
if data.wallet not in user.wallet_ids:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to update pay to install data for extension '{ext_id}' "),
) from exc
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
)
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")
async def api_enable_extension(
ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]:
if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
@@ -196,28 +181,34 @@ async def api_enable_extension(
assert ext, f"Extension '{ext_id}' is not installed."
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:
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.")
user_ext = await get_user_extension(user.id, ext_id)
if not (user_ext and user_ext.extra and user_ext.extra.payment_hash_to_enable):
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
)
if user_ext.is_paid:
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"Paid extension '{ext_id}' enabled."
)
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."
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,
)
@@ -227,10 +218,9 @@ async def api_enable_extension(
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension_extra(user.id, ext_id, user_ext.extra)
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
except AssertionError as exc:
@@ -249,20 +239,19 @@ async def api_enable_extension(
async def api_disable_extension(
ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]:
if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
)
try:
logger.info(f"Disabeling extension: {ext_id}.")
await update_user_extension(user_id=user.id, extension=ext_id, active=False)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to disable '{ext_id}'."),
) from exc
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext or not user_ext.active:
return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled."
)
logger.info(f"Disabeling extension: {ext_id}.")
user_ext.active = False
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
@@ -270,20 +259,14 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Activating extension: '{ext_id}'.")
all_extensions = get_valid_extensions()
ext = next((e for e in all_extensions if e.code == ext_id), None)
ext = await get_valid_extension(ext_id)
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 update_installed_extension_state(ext_id=ext_id, active=True)
await activate_extension(ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
except Exception as exc:
logger.warning(exc)
await deactivate_extension(ext_id)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to activate '{ext_id}'."),
@@ -295,13 +278,10 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Deactivating extension: '{ext_id}'.")
all_extensions = get_valid_extensions()
ext = next((e for e in all_extensions if e.code == ext_id), None)
ext = await get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist."
settings.lnbits_deactivated_extensions.add(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
await deactivate_extension(ext_id)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
except Exception as exc:
logger.warning(exc)
@@ -311,27 +291,27 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
) from exc
@extension_router.delete("/{ext_id}")
async def api_uninstall_extension(
ext_id: str,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
) -> SimpleStatus:
installed_extensions = await get_installed_extensions()
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
extensions = [e for e in installed_extensions if e.id == ext_id]
if len(extensions) == 0:
extension = await get_installed_extension(ext_id)
if not extension:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Unknown extension id: {ext_id}",
)
installed_extensions = await get_installed_extensions()
# 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 await get_valid_extensions()]:
installed_ext = next(
(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(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
@@ -341,14 +321,7 @@ async def api_uninstall_extension(
)
try:
# call stop while the old routes are still active
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)
await uninstall_extension(ext_id)
logger.success(f"Extension '{ext_id}' uninstalled.")
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
@@ -359,9 +332,9 @@ async def api_uninstall_extension(
@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:
extension_releases: List[ExtensionRelease] = (
extension_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
@@ -397,9 +370,8 @@ async def get_pay_to_install_invoice(
assert release, "Release not found."
assert release.pay_link, "Pay link not found for release."
payment_info = await fetch_release_payment_info(
release.pay_link, data.cost_sats
)
payment_info = await release.fetch_release_payment_info(data.cost_sats)
assert payment_info and payment_info.payment_request, "Cannot request invoice."
invoice = bolt11_decode(payment_info.payment_request)
@@ -427,54 +399,68 @@ async def get_pay_to_install_invoice(
async def get_pay_to_enable_invoice(
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
):
try:
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)
if not data.amount or data.amount <= 0:
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice."
) from exc
status_code=HTTPStatus.BAD_REQUEST, detail="Amount must be greater than 0."
)
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 = 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.payment_hash
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
@extension_router.get(
"/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_admin)],
dependencies=[Depends(check_user_exists)],
)
async def get_extension_release(org: str, repo: str, tag_name: str):
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:
return {}
@@ -489,13 +475,26 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
) from exc
@extension_router.get("")
async def api_get_user_extensions(
user: User = Depends(check_user_exists),
) -> list[Extension]:
user_extensions_ids = [ue.extension for ue in await get_user_extensions(user.id)]
return [
ext
for ext in await get_valid_extensions(False)
if ext.code in user_extensions_ids
]
@extension_router.delete(
"/{ext_id}/db",
dependencies=[Depends(check_admin)],
)
async def delete_extension_db(ext_id: str):
try:
db_version = (await get_dbversions()).get(ext_id, None)
db_version = await get_db_version(ext_id)
if not db_version:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
+160 -91
View File
@@ -1,29 +1,33 @@
from http import HTTPStatus
from pathlib import Path
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.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter
from loguru import logger
from lnurl import decode as lnurl_decode
from pydantic.types import UUID4
from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User
from lnbits.core.models.extensions import ExtensionMeta, InstallableExtension
from lnbits.core.services import create_invoice, create_user_account
from lnbits.core.services.extensions import get_valid_extensions
from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from ...extension_manager import InstallableExtension, get_valid_extensions
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_wallet,
get_dbversions,
get_db_versions,
get_installed_extensions,
get_user,
get_wallet,
)
generic_router = APIRouter(
@@ -70,83 +74,87 @@ async def robots():
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
try:
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [
e for e in installed_exts if e.id not in installable_exts_ids
]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext:
e.installed_release = installed_ext.installed_release
if installed_ext.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.pay_to_enable.wallet = None
e.pay_to_enable = installed_ext.pay_to_enable
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
except Exception as ex:
logger.warning(ex)
installable_exts = []
installed_exts_ids = []
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_versions = await get_db_versions()
try:
all_ext_ids = [ext.code for ext in get_valid_extensions()]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False)
]
db_version = await get_dbversions()
extensions = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.featured,
"dependencies": ext.dependencies,
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.latest_release) if ext.latest_release else None
),
"installedRelease": (
dict(ext.installed_release) if ext.installed_release else None
),
"payToEnable": (dict(ext.pay_to_enable) if ext.pay_to_enable else {}),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
extensions = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
# refresh user state. Eg: enabled extensions.
user = await get_user(user.id) or user
# refresh user state. Eg: enabled extensions.
# TODO: refactor
# user = await get_user(user.id) or user
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"user": user.dict(),
"extensions": extensions,
},
)
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"user": user.json(),
"extensions": extensions,
},
)
@generic_router.get(
@@ -161,18 +169,16 @@ async def wallet(
wal: Optional[UUID4] = Query(None),
):
if wal:
wallet_id = wal.hex
wallet = await get_wallet(wal.hex)
elif len(user.wallets) == 0:
wallet = await create_wallet(user_id=user.id)
user = await get_user(user_id=user.id) or user
wallet_id = wallet.id
user.wallets.append(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:
wallet_id = user.wallets[0].id
wallet = user.wallets[0]
user_wallet = user.get_wallet(wallet_id)
if not user_wallet or user_wallet.deleted:
if not wallet or wallet.deleted:
return template_renderer().TemplateResponse(
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
)
@@ -181,15 +187,16 @@ async def wallet(
request,
"core/wallet.html",
{
"user": user.dict(),
"wallet": user_wallet.dict(),
"user": user.json(),
"wallet": wallet.json(),
"wallet_name": wallet.name,
"currencies": allowed_currencies(),
"service_fee": settings.lnbits_service_fee,
"service_fee_max": settings.lnbits_service_fee_max,
"web_manifest": f"/manifest/{user.id}.webmanifest",
},
)
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
resp.set_cookie("lnbits_last_active_wallet", wallet.id)
return resp
@@ -206,7 +213,7 @@ async def account(
request,
"core/account.html",
{
"user": user.dict(),
"user": user.json(),
},
)
@@ -226,11 +233,9 @@ async def service_worker(request: Request):
@generic_router.get("/manifest/{usr}.webmanifest")
async def manifest(request: Request, usr: str):
host = urlparse(str(request.url)).netloc
user = await get_user(usr)
if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return {
"short_name": settings.lnbits_site_title,
"name": settings.lnbits_site_title + " Wallet",
@@ -318,10 +323,10 @@ async def node(request: Request, user: User = Depends(check_admin)):
request,
"node/index.html",
{
"user": user.dict(),
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"wallets": user.wallets[0].dict(),
"wallets": user.wallets[0].json(),
},
)
@@ -356,7 +361,7 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
request,
"admin/index.html",
{
"user": user.dict(),
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"currencies": list(currencies.keys()),
@@ -373,13 +378,27 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
"users/index.html",
{
"request": request,
"user": user.dict(),
"user": user.json(),
"settings": settings.dict(),
"currencies": list(currencies.keys()),
},
)
@generic_router.get("/audit", response_class=HTMLResponse)
async def audit_index(request: Request, user: User = Depends(check_admin)):
if not settings.lnbits_audit_enabled:
raise HTTPException(HTTPStatus.NOT_FOUND, "Audit not enabled")
return template_renderer().TemplateResponse(
"audit/index.html",
{
"request": request,
"user": user.json(),
},
)
@generic_router.get("/uuidv4/{hex_value}")
async def hex_to_uuid4(hex_value: str):
try:
@@ -389,3 +408,53 @@ async def hex_to_uuid4(hex_value: str):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(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}",
)
+109 -93
View File
@@ -3,13 +3,12 @@ import json
import uuid
from http import HTTPStatus
from math import ceil
from typing import List, Optional, Union
from typing import List, Optional
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
Body,
Depends,
Header,
HTTPException,
@@ -21,12 +20,12 @@ from loguru import logger
from sse_starlette.sse import EventSourceResponse
from lnbits import bolt11
from lnbits.core.db import db
from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
DecodePayment,
KeyType,
PayLnurlWData,
Payment,
PaymentFilters,
PaymentHistoryPoint,
@@ -35,12 +34,11 @@ from lnbits.core.models import (
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
parse_filters,
require_admin_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.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
@@ -52,13 +50,12 @@ from ..crud import (
get_payments_paginated,
get_standalone_payment,
get_wallet_for_key,
update_pending_payments,
)
from ..services import (
check_transaction_status,
create_invoice,
fee_reserve_total,
pay_invoice,
update_pending_payments,
)
from ..tasks import api_invoice_listeners
@@ -74,12 +71,12 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments(
wallet: WalletTypeInfo = Depends(get_key_type),
key_info: WalletTypeInfo = Depends(require_invoice_key),
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(
wallet_id=wallet.wallet.id,
wallet_id=key_info.wallet.id,
pending=True,
complete=True,
filters=filters,
@@ -93,12 +90,12 @@ async def api_payments(
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_history(
wallet: WalletTypeInfo = Depends(get_key_type),
key_info: WalletTypeInfo = Depends(require_invoice_key),
group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
return await get_payments_history(wallet.wallet.id, group, filters)
await update_pending_payments(key_info.wallet.id)
return await get_payments_history(key_info.wallet.id, group, filters)
@payment_router.get(
@@ -110,12 +107,12 @@ async def api_payments_history(
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_paginated(
wallet: WalletTypeInfo = Depends(get_key_type),
key_info: WalletTypeInfo = Depends(require_invoice_key),
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(
wallet_id=wallet.wallet.id,
wallet_id=key_info.wallet.id,
pending=True,
complete=True,
filters=filters,
@@ -123,7 +120,7 @@ async def api_payments_paginated(
return page
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
@@ -147,60 +144,42 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
# do not save memo if description_hash or unhashed_description is set
memo = ""
async with db.connect() as conn:
payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
conn=conn,
)
# NOTE: we get the checking_id with a seperate query because create_invoice
# does not return it and it would be a big hustle to change its return type
# (used across extensions)
payment_db = await get_standalone_payment(payment_hash, conn=conn)
assert payment_db is not None, "payment not found"
checking_id = payment_db.checking_id
payment = await create_invoice(
wallet_id=wallet.id,
amount=data.amount,
memo=memo,
currency=data.unit,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=data.expiry,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
)
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
# lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.get(
data.lnurl_callback,
params={
"pr": payment_request,
},
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
lnurl_response = r.text
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
lnurl_response = resp["reason"]
payment.extra["lnurl_response"] = resp["reason"]
else:
lnurl_response = True
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
lnurl_response = False
payment.extra["lnurl_response"] = False
return {
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
"lnurl_response": lnurl_response,
# maintain backwards compatibility with API clients:
"checking_id": checking_id,
}
return payment
@payment_router.post(
@@ -222,30 +201,25 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
},
)
async def api_payments_create(
invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
invoice_data: CreateInvoice = Body(...),
):
) -> Payment:
if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="BOLT11 string is invalid or not given",
detail="Missing BOLT11 invoice",
)
payment_hash = await pay_invoice(
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
return payment
elif not invoice_data.out:
# invoice key
return await api_payments_create_invoice(invoice_data, wallet.wallet)
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
@@ -271,7 +245,7 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
):
) -> Payment:
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
@@ -315,15 +289,12 @@ async def api_payments_pay_lnurl(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
@@ -332,19 +303,14 @@ async def api_payments_pay_lnurl(
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
payment_hash = await pay_invoice(
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
return payment
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
@@ -379,10 +345,10 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
@payment_router.get("/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(
subscribe_wallet_invoices(request, wallet.wallet),
subscribe_wallet_invoices(request, key_info.wallet),
ping=20,
media_type="text/event-stream",
)
@@ -402,15 +368,8 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
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 payment.success:
if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage}
@@ -424,12 +383,12 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
if wallet and wallet.id == payment.wallet_id:
return {
"paid": not payment.pending,
"paid": payment.success,
"status": f"{status!s}",
"preimage": payment.preimage,
"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)
@@ -441,9 +400,66 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
return JSONResponse({"domain": url})
else:
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:
return JSONResponse(
{"message": f"Failed to decode: {exc!s}"},
status_code=HTTPStatus.BAD_REQUEST,
)
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: PayLnurlWData,
) -> JSONResponse:
lnurl = lnurl_data.lnurl_w.lower()
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
url = lnurl.replace("lnurlw://", "https://")
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
lnurl_req = await client.get(url, timeout=10)
if lnurl_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading LNURL request"}
)
lnurl_res = lnurl_req.json()
if lnurl_res.get("status") == "ERROR":
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
if lnurl_res.get("tag") != "withdrawRequest":
return JSONResponse(
{"success": False, "detail": "Invalid LNURL-withdraw"}
)
callback_url = lnurl_res["callback"]
k1 = lnurl_res["k1"]
callback_req = await client.get(
callback_url,
params={"k1": k1, "pr": payment_request},
timeout=10,
)
if callback_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading callback request"}
)
callback_res = callback_req.json()
if callback_res.get("status") == "ERROR":
return JSONResponse(
{"success": False, "detail": callback_res["reason"]}
)
else:
return JSONResponse({"success": True, "detail": callback_res})
except Exception as e:
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
+2 -1
View File
@@ -20,7 +20,8 @@ async def api_public_payment_longpolling(payment_hash):
raise HTTPException(
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"}
try:
+225 -102
View File
@@ -1,159 +1,282 @@
import base64
import json
import time
from http import HTTPStatus
from typing import List
from typing import List, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends
from starlette.exceptions import HTTPException
import shortuuid
from fastapi import APIRouter, Body, Depends
from fastapi.exceptions import HTTPException
from lnbits.core.crud import (
create_wallet,
delete_account,
delete_wallet,
force_delete_wallet,
get_accounts,
get_user,
get_wallet,
get_wallets,
update_admin_settings,
update_wallet,
)
from lnbits.core.models import (
Account,
AccountFilters,
AccountOverview,
CreateTopup,
CreateUser,
SimpleStatus,
User,
UserExtra,
Wallet,
)
from lnbits.core.services import update_wallet_balance
from lnbits.core.models.users import Account
from lnbits.core.services import (
create_user_account_no_ckeck,
update_user_account,
update_user_extensions,
update_wallet_balance,
)
from lnbits.db import Filters, Page
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.utils.exchange_rates import allowed_currencies
users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_admin)])
users_router = APIRouter(
prefix="/users/api/v1", dependencies=[Depends(check_admin)], tags=["Users"]
)
@users_router.get(
"/user",
name="get accounts",
name="Get accounts",
summary="Get paginated list of accounts",
openapi_extra=generate_filter_params_openapi(AccountFilters),
)
async def api_get_users(
filters: Filters = Depends(parse_filters(AccountFilters)),
) -> Page[Account]:
try:
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:
) -> Page[AccountOverview]:
return await get_accounts(filters=filters)
@users_router.get(
"/user/{user_id}",
name="Get user",
summary="Get user by Id",
)
async def api_get_user(user_id: str) -> User:
user = await get_user(user_id)
if not user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return user
@users_router.post("/user", name="Create user")
async def api_create_user(data: CreateUser) -> CreateUser:
if not data.username and data.password:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch users. {exc!s}",
) from exc
HTTPStatus.BAD_REQUEST, "Username required when password provided."
)
if data.password != data.password_repeat:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Passwords do not match.")
if not data.password:
random_password = shortuuid.uuid()
data.password = random_password
data.password_repeat = random_password
data.extra = data.extra or UserExtra()
data.extra.provider = data.extra.provider or "lnbits"
account = Account(
id=uuid4().hex,
username=data.username,
email=data.email,
pubkey=data.pubkey,
extra=data.extra,
)
account.validate_fields()
account.hash_password(data.password)
user = await create_user_account_no_ckeck(account)
data.id = user.id
return data
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
@users_router.put("/user/{user_id}", name="Update user")
async def api_update_user(user_id: str, data: CreateUser) -> CreateUser:
if user_id != data.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
if data.password or data.password_repeat:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
)
account = Account(
id=user_id,
username=data.username,
email=data.email,
pubkey=data.pubkey,
extra=data.extra or UserExtra(),
)
await update_user_account(account)
await update_user_extensions(user_id, data.extensions or [])
return data
@users_router.delete(
"/user/{user_id}",
status_code=HTTPStatus.OK,
name="Delete user by Id",
)
async def api_users_delete_user(
user_id: str, user: User = Depends(check_admin)
) -> None:
try:
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:
) -> SimpleStatus:
wallets = await get_wallets(user_id)
if len(wallets) > 0:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete user with wallets.",
)
@users_router.get("/user/{user_id}/admin", dependencies=[Depends(check_super_user)])
async def api_users_toggle_admin(user_id: str) -> None:
try:
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:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not update admin settings. {exc}",
) from exc
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)
return SimpleStatus(success=True, message="User deleted.")
@users_router.get("/user/{user_id}/wallet")
@users_router.put(
"/user/{user_id}/reset_password",
dependencies=[Depends(check_super_user)],
name="Reset user password",
)
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)],
name="Give or revoke admin permsisions to a user",
)
async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot change super user.",
)
if settings.is_admin_user(user_id):
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)
return SimpleStatus(
success=True, message=f"User admin: '{settings.is_admin_user(user_id)}'."
)
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
try:
return await get_wallets(user_id)
except Exception as exc:
return await get_wallets(user_id)
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
async def api_users_create_user_wallet(
user_id: str, name: Optional[str] = Body(None), currency: Optional[str] = Body(None)
):
if currency and currency not in allowed_currencies():
raise ValueError(f"Currency '{currency}' not allowed.")
wallet = await create_wallet(user_id=user_id, wallet_name=name)
if currency:
wallet.currency = currency
await update_wallet(wallet)
return wallet
@users_router.put(
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
)
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch user wallets. {exc}",
) from exc
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
@users_router.get("/user/{user_id}/wallet/{wallet}/undelete")
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> None:
try:
wal = await get_wallet(wallet)
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:
if user_id != wal.user:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
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)
return SimpleStatus(success=True, message="Wallet undeleted.")
return SimpleStatus(success=True, message="Wallet is already active.")
@users_router.delete("/user/{user_id}/wallet/{wallet}")
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
try:
wal = await get_wallet(wallet)
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:
@users_router.delete(
"/user/{user_id}/wallet/{wallet}",
name="Delete wallet by id",
summary="First time it is called it does a soft delete (only sets a flag)."
"The second time it is called will delete the entry from the DB",
)
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
return SimpleStatus(success=True, message="Wallet deleted.")
@users_router.put(
"/topup",
name="Topup",
summary="Update balance for a particular wallet.",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_super_user)],
)
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
try:
await get_wallet(data.id)
if settings.lnbits_backend_wallet_class == "VoidWallet":
raise Exception("VoidWallet active")
async def api_topup_balance(data: CreateTopup) -> SimpleStatus:
await get_wallet(data.id)
if settings.lnbits_backend_wallet_class == "VoidWallet":
raise Exception("VoidWallet active")
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return {"status": "Success"}
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"{exc!s}"
) from exc
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return SimpleStatus(success=True, message="Balance updated.")
+34 -22
View File
@@ -1,9 +1,11 @@
from http import HTTPStatus
from typing import Optional
from fastapi import (
APIRouter,
Body,
Depends,
HTTPException,
)
from lnbits.core.models import (
@@ -13,13 +15,14 @@ from lnbits.core.models import (
)
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
require_invoice_key,
)
from ..crud import (
create_wallet,
delete_wallet,
get_wallet,
update_wallet,
)
@@ -27,36 +30,45 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
@wallet_router.get("")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.key_type == KeyType.admin:
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
else:
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
res = {
"name": key_info.wallet.name,
"balance": key_info.wallet.balance_msat,
}
if key_info.key_type == KeyType.admin:
res["id"] = key_info.wallet.id
return res
@wallet_router.put("/{new_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 {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
"id": wallet.id,
"name": wallet.name,
"balance": wallet.balance_msat,
}
@wallet_router.patch("", response_model=Wallet)
@wallet_router.patch("")
async def api_update_wallet(
name: Optional[str] = Body(None),
currency: Optional[str] = Body(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
return await update_wallet(wallet.wallet.id, name, currency)
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
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("")
@@ -69,9 +81,9 @@ async def api_delete_wallet(
)
@wallet_router.post("", response_model=Wallet)
@wallet_router.post("")
async def api_create_wallet(
data: CreateWallet,
wallet: WalletTypeInfo = Depends(require_admin_key),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> 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)
+307 -133
View File
@@ -1,20 +1,20 @@
from __future__ import annotations
import asyncio
import datetime
import json
import os
import re
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum
from sqlite3 import Row
from typing import Any, Generic, Literal, Optional, TypeVar
from typing import Any, Generic, Literal, Optional, TypeVar, Union, get_origin
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
from sqlalchemy import create_engine
from sqlalchemy_aio.base import AsyncConnection
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text
from lnbits.settings import settings
@@ -24,31 +24,15 @@ SQLITE = "SQLITE"
if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"):
DB_TYPE = COCKROACH
else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' " "format for the database URL."
)
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:
if not os.path.isdir(settings.lnbits_data_folder):
os.mkdir(settings.lnbits_data_folder)
@@ -56,13 +40,21 @@ else:
DB_TYPE = SQLITE
def compat_timestamp_placeholder():
def compat_timestamp_placeholder(key: str):
if DB_TYPE == POSTGRES:
return "to_timestamp(?)"
return f"to_timestamp(:{key})"
elif DB_TYPE == COCKROACH:
return "cast(? AS timestamp)"
return f"cast(:{key} AS timestamp)"
else:
return "?"
return f":{key}"
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime:
return compat_timestamp_placeholder(field)
else:
return f":{field}"
class Compat:
@@ -76,7 +68,7 @@ class Compat:
return f"{seconds}"
return "<nothing>"
def datetime_to_timestamp(self, date: datetime.datetime):
def datetime_to_timestamp(self, date: datetime):
if self.type in {POSTGRES, COCKROACH}:
return date.strftime("%Y-%m-%d %H:%M:%S")
elif self.type == SQLITE:
@@ -119,15 +111,13 @@ class Compat:
return "BIGINT"
return "INT"
@property
def timestamp_placeholder(self) -> str:
return compat_timestamp_placeholder()
def timestamp_placeholder(self, key: str) -> str:
return compat_timestamp_placeholder(key)
class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
def __init__(self, conn: AsyncConnection, typ, name, schema):
self.conn = conn
self.txn = txn
self.type = typ
self.name = name
self.schema = schema
@@ -138,49 +128,76 @@ class Connection(Compat):
query = query.replace("?", "%s")
return query
def rewrite_values(self, values):
def rewrite_values(self, values: dict) -> dict:
# strip html
clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
# tuple to list and back to tuple
raw_values = [values] if isinstance(values, str) else list(values)
values = []
for raw_value in raw_values:
clean_values: dict = {}
for key, raw_value in values.items():
if isinstance(raw_value, str):
values.append(re.sub(clean_regex, "", raw_value))
elif isinstance(raw_value, datetime.datetime):
clean_values[key] = re.sub(clean_regex, "", raw_value)
elif isinstance(raw_value, datetime):
ts = raw_value.timestamp()
if self.type == SQLITE:
values.append(int(ts))
clean_values[key] = int(ts)
else:
values.append(ts)
clean_values[key] = ts
else:
values.append(raw_value)
return tuple(values)
clean_values[key] = raw_value
return clean_values
async def fetchall(self, query: str, values: tuple = ()) -> list:
result = await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()):
result = await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
row = await result.fetchone()
await result.close()
async def fetchall(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> list[TModel]:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
row = result.mappings().all()
result.close()
if not row:
return []
if model:
return [dict_to_model(r, model) for r in 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(
self,
query: str,
where: Optional[list[str]] = None,
values: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None,
) -> Page[TRowModel]:
) -> Page[TModel]:
if not filters:
filters = Filters()
clause = filters.where(where)
@@ -203,14 +220,15 @@ class Connection(Compat):
{filters.order_by()}
{filters.pagination()}
""",
parsed_values,
self.rewrite_values(parsed_values),
model,
)
if rows:
# no need for extra query if no pagination is specified
if filters.offset or filters.limit:
count = await self.fetchone(
result = await self.execute(
f"""
SELECT COUNT(*) FROM (
SELECT COUNT(*) as count FROM (
{query}
{clause}
{group_by_string}
@@ -218,21 +236,24 @@ class Connection(Compat):
""",
parsed_values,
)
count = int(count[0])
row = result.mappings().first()
result.close()
count = int(row.get("count", 0))
else:
count = len(rows)
else:
count = 0
return Page(
data=[model.from_row(row) for row in rows] if model else rows,
data=rows,
total=count,
)
async def execute(self, query: str, values: tuple = ()):
return await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
async def execute(self, query: str, values: Optional[dict] = None):
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
await self.conn.commit()
return result
class Database(Compat):
@@ -245,18 +266,42 @@ class Database(Compat):
self.path = os.path.join(
settings.lnbits_data_folder, f"{self.name}.sqlite3"
)
database_uri = f"sqlite:///{self.path}"
database_uri = f"sqlite+aiosqlite:///{self.path}"
else:
database_uri = settings.lnbits_database_url
database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
if self.name.startswith("ext_"):
self.schema = self.name[4:]
else:
self.schema = None
self.engine = create_engine(
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
self.engine: AsyncEngine = create_async_engine(
database_uri, echo=settings.debug_database
)
if self.type in {POSTGRES, COCKROACH}:
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_date(value) -> datetime:
if value is None:
value = "1970-01-01 00:00:00"
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return datetime.strptime(value, f)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime,
decoder=_parse_date,
schema="pg_catalog",
)
)
self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}")
@@ -265,49 +310,65 @@ class Database(Compat):
async def connect(self):
await self.lock.acquire()
try:
async with self.engine.connect() as conn: # type: ignore
async with conn.begin() as txn:
wconn = Connection(conn, txn, self.type, self.name, self.schema)
async with self.engine.connect() as conn:
if not conn:
raise Exception("Could not connect to the database")
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}"
)
wconn = Connection(conn, self.type, self.name, 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:
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:
result = await conn.execute(query, values)
return await result.fetchall()
return await conn.fetchall(query, values, model)
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:
result = await conn.execute(query, values)
row = await result.fetchone()
await result.close()
return row
return await conn.fetchone(query, values, model)
async def insert(self, table_name: str, model: BaseModel) -> None:
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(
self,
query: str,
where: Optional[list[str]] = None,
values: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None,
) -> Page[TRowModel]:
) -> Page[TModel]:
async with self.connect() as conn:
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:
return await conn.execute(query, values)
@@ -363,12 +424,6 @@ class Operator(Enum):
raise ValueError("Unknown SQL Operator")
class FromRowModel(BaseModel):
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class FilterModel(BaseModel):
__search_fields__: list[str] = []
__sort_fields__: Optional[list[str]] = None
@@ -376,7 +431,6 @@ class FilterModel(BaseModel):
T = TypeVar("T")
TModel = TypeVar("TModel", bound=BaseModel)
TRowModel = TypeVar("TRowModel", bound=FromRowModel)
TFilterModel = TypeVar("TFilterModel", bound=FilterModel)
@@ -388,12 +442,13 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]):
field: str
op: Operator = Operator.EQ
values: list[Any]
model: Optional[type[TFilterModel]]
values: Optional[dict] = None
@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[operator]
# e.g. name[eq]
@@ -409,12 +464,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__:
compare_field = model.__fields__[field]
values = []
values: dict = {}
for raw_value in raw_values:
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
values.append(validated)
values[f"{field}__{i}"] = validated
else:
raise ValueError("Unknown filter field")
@@ -422,15 +477,14 @@ class Filter(BaseModel, Generic[TFilterModel]):
@property
def statement(self):
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
placeholder = compat_timestamp_placeholder()
else:
placeholder = "?"
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
placeholders = ", ".join([placeholder] * len(self.values))
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
else:
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values)
stmt = []
for key in self.values.keys() if self.values else []:
clean_key = key.split("__")[0]
if self.model and self.model.__fields__[clean_key].type_ == datetime:
placeholder = compat_timestamp_placeholder(key)
else:
placeholder = f":{key}"
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
return " OR ".join(stmt)
@@ -481,14 +535,11 @@ class Filters(BaseModel, Generic[TFilterModel]):
for page_filter in self.filters:
where_stmts.append(page_filter.statement)
if self.search and self.model:
fields = self.model.__search_fields__
if DB_TYPE == POSTGRES:
where_stmts.append(
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
)
where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
elif DB_TYPE == SQLITE:
where_stmts.append(
f"lower({'||'.join(self.model.__search_fields__)}) LIKE ?"
)
where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
if where_stmts:
return "WHERE " + " AND ".join(where_stmts)
return ""
@@ -498,12 +549,135 @@ class Filters(BaseModel, Generic[TFilterModel]):
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return ""
def values(self, values: Optional[list[str]] = None) -> tuple:
def values(self, values: Optional[dict] = None) -> dict:
if not values:
values = []
values = {}
if 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:
values.append(f"%{self.search}%")
return tuple(values)
values["search"] = f"%{self.search}%"
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_
outertype_ = model.__fields__[key].outer_type_
if model.__fields__[key].field_info.extra.get("no_database", False):
continue
if isinstance(value, datetime):
_dict[key] = value.timestamp()
continue
if (
type(type_) is type(BaseModel)
or type_ is dict
or get_origin(outertype_) is list
):
_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
# 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 value is None:
continue
if key not in model.__fields__:
# Somethimes an SQL JOIN will create additional column
continue
type_ = model.__fields__[key].type_
outertype_ = model.__fields__[key].outer_type_
if get_origin(outertype_) is list:
_items = json.loads(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
for v in _items
]
continue
if issubclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, datetime):
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value
continue
if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
# NOTE: this is for type dict on BaseModel, (used in Payment class)
if type_ is dict and value:
_dict[key] = json.loads(value)
continue
_dict[key] = value
continue
_model = model.construct(**_dict)
if isinstance(_model, BaseModel):
_model.__init__(**_dict) # type: ignore
return _model
+58 -34
View File
@@ -1,12 +1,12 @@
from http import HTTPStatus
from typing import Annotated, Literal, Optional, Type, Union
import jwt
from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase
from jose import ExpiredSignatureError, JWTError, jwt
from loguru import logger
from pydantic.types import UUID4
@@ -14,12 +14,19 @@ from lnbits.core.crud import (
get_account,
get_account_by_email,
get_account_by_username,
get_user,
get_user_active_extensions_ids,
get_user_from_account,
get_wallet_for_key,
)
from lnbits.core.models import KeyType, SimpleStatus, User, WalletTypeInfo
from lnbits.db import Filter, Filters, TFilterModel
from lnbits.core.models import (
AccessTokenPayload,
Account,
KeyType,
SimpleStatus,
User,
WalletTypeInfo,
)
from lnbits.db import Connection, Filter, Filters, TFilterModel
from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
@@ -59,7 +66,7 @@ class KeyChecker(SecurityBase):
name="X-API-KEY",
description="Wallet API Key - HEADER",
)
self.model: APIKey = openapi_model
self.model: APIKey = openapi_model # type: ignore
async def __call__(self, request: Request) -> WalletTypeInfo:
@@ -83,6 +90,7 @@ class KeyChecker(SecurityBase):
detail="Wallet not found.",
)
request.scope["user_id"] = wallet.user
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
@@ -95,15 +103,6 @@ class KeyChecker(SecurityBase):
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(
request: Request,
api_key_header: str = Security(api_key_header),
@@ -147,14 +146,17 @@ async def check_user_exists(
else:
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.")
r.scope["user_id"] = account.id
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
user = await get_user(account.id)
assert user, "User not found for account."
user = await get_user_from_account(account)
if not user:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
await _check_user_extension_access(user.id, r["path"])
return user
@@ -171,6 +173,16 @@ async def optional_user_id(
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:
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
raise HTTPException(
@@ -204,9 +216,9 @@ def parse_filters(model: Type[TFilterModel]):
):
params = request.query_params
filters = []
for key in params.keys():
for i, key in enumerate(params.keys()):
try:
filters.append(Filter.parse_query(key, params.getlist(key), model))
filters.append(Filter.parse_query(key, params.getlist(key), model, i))
except ValueError:
continue
@@ -223,7 +235,9 @@ def parse_filters(model: Type[TFilterModel]):
return dependency
async def check_user_extension_access(user_id: str, ext_id: str) -> SimpleStatus:
async def check_user_extension_access(
user_id: str, ext_id: str, conn: Optional[Connection] = None
) -> SimpleStatus:
"""
Check if the user has access to a particular extension.
Raises HTTP Forbidden if the user is not allowed.
@@ -234,7 +248,7 @@ async def check_user_extension_access(user_id: str, ext_id: str) -> SimpleStatus
)
if settings.is_extension_id(ext_id):
ext_ids = await get_user_active_extensions_ids(user_id)
ext_ids = await get_user_active_extensions_ids(user_id, conn=conn)
if ext_id not in ext_ids:
return SimpleStatus(
success=False, message=f"User extension '{ext_id}' not enabled."
@@ -254,21 +268,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:
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
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")))
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
user = await _get_user_from_jwt_payload(payload)
if not user:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Data missing for access token."
)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
except ExpiredSignatureError as exc:
return user
except jwt.ExpiredSignatureError as exc:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
) from exc
except JWTError as exc:
except jwt.PyJWTError as exc:
logger.debug(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
+55 -41
View File
@@ -8,52 +8,54 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse, Response
from loguru import logger
from lnbits.core.services import InvoiceError, PaymentError
from .helpers import template_renderer
def register_exception_handlers(app: FastAPI):
register_exception_handler(app)
register_request_validation_exception_handler(app)
register_http_exception_handler(app)
register_payment_error_handler(app)
register_invoice_error_handler(app)
class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class InvoiceError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if not request.headers:
return None
if "text/html" not in request.headers.get("accept", ""):
return None
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
isinstance(exc, HTTPException)
and exc.headers
and "token-expired" in exc.headers
):
if (
isinstance(exc, HTTPException)
and exc.headers
and "token-expired" in exc.headers
):
response = RedirectResponse("/")
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.set_cookie("is_access_token_expired", "true")
return response
response = RedirectResponse("/")
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.set_cookie("is_access_token_expired", "true")
return response
status_code: int = (
exc.status_code
if isinstance(exc, HTTPException)
else HTTPStatus.INTERNAL_SERVER_ERROR
)
status_code: int = (
exc.status_code
if isinstance(exc, HTTPException)
else HTTPStatus.INTERNAL_SERVER_ERROR
)
return template_renderer().TemplateResponse(
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
)
return None
return template_renderer().TemplateResponse(
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
)
def register_exception_handler(app: FastAPI):
def register_exception_handlers(app: FastAPI):
"""Register exception handlers for the FastAPI app"""
@app.exception_handler(Exception)
async def exception_handler(request: Request, exc: Exception):
etype, _, tb = sys.exc_info()
@@ -64,8 +66,26 @@ def register_exception_handler(app: FastAPI):
content={"detail": str(exc)},
)
@app.exception_handler(AssertionError)
async def assert_error_handler(request: Request, exc: AssertionError):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
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):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
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)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
@@ -76,8 +96,6 @@ def register_request_validation_exception_handler(app: FastAPI):
content={"detail": str(exc)},
)
def register_http_exception_handler(app: FastAPI):
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
@@ -86,21 +104,17 @@ def register_http_exception_handler(app: FastAPI):
content={"detail": exc.detail},
)
def register_payment_error_handler(app: FastAPI):
@app.exception_handler(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(
status_code=520,
content={"detail": exc.message, "status": exc.status},
)
def register_invoice_error_handler(app: FastAPI):
@app.exception_handler(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(
status_code=520,
content={"detail": exc.message, "status": exc.status},
+62 -30
View File
@@ -1,13 +1,15 @@
import hashlib
import json
import re
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, List, Optional, Type
from typing import Any, Optional, Type
from urllib import request
import jinja2
import jwt
import shortuuid
from jose import jwt
from pydantic import BaseModel
from packaging import version
from pydantic.schema import field_schema
from lnbits.jinja2_templating import Jinja2Templates
@@ -17,7 +19,6 @@ from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from .db import FilterModel
from .extension_manager import get_valid_extensions
def get_db_vendor_name():
@@ -50,7 +51,7 @@ def static_url_for(static: str, path: str) -> str:
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"]
if additional_folders:
additional_folders += [
@@ -85,6 +86,8 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
settings.lnbits_extensions_deactivate_all
)
t.env.globals["LNBITS_AUDIT_ENABLED"] = settings.lnbits_audit_enabled
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
@@ -92,19 +95,22 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
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["EXTENSIONS"] = get_valid_extensions(False)
t.env.globals["EXTENSIONS"] = list(settings.lnbits_all_extensions_ids)
if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
if settings.bundle_assets:
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
t.env.globals["INCLUDED_COMPONENTS"] = ["bundle-components.min.js"]
else:
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
with open(vendor_filepath) as vendor_file:
vendor_files = json.loads(vendor_file.read())
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
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
@@ -172,28 +178,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:
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
@@ -204,8 +188,20 @@ def is_valid_username(username: str) -> bool:
return re.fullmatch(username_regex, username) is not None
def is_valid_pubkey(pubkey: str) -> bool:
if len(pubkey) != 64:
return False
try:
int(pubkey, 16)
return True
except Exception as _:
return False
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.update({"exp": expire})
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
@@ -223,3 +219,39 @@ def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
if not m:
return None
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}
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:
# remove release candidate suffix
v = v.split("-")[0].split("rc")[0]
return version.parse(v)
except Exception:
return version.parse("0.0.0")
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()
+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",
]
+94 -69
View File
@@ -1,15 +1,21 @@
import asyncio
import json
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any, List, Tuple, Union
from typing import Any, List, Optional, Union
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from loguru import logger
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.types import ASGIApp, Receive, Scope, Send
from lnbits.core.db import core_app_extra
from lnbits.core.models import AuditEntry
from lnbits.helpers import template_renderer
from lnbits.settings import settings
@@ -45,16 +51,11 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send)
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
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)
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
@@ -118,71 +119,99 @@ class ExtensionsRedirectMiddleware:
return
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:
scope["path"] = self._new_path(redirect, scope["path"])
scope["path"] = redirect.new_path_from(scope["path"])
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
)
class AuditMiddleware(BaseHTTPMiddleware):
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 __init__(self, app: ASGIApp, audit_queue: asyncio.Queue) -> None:
super().__init__(app)
self.audit_queue = audit_queue
# delete_time purge after X days
# time, # include pats, exclude paths (regex)
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
async def dispatch(self, request: Request, call_next) -> Response:
start_time = datetime.now(timezone.utc)
request_details = await self._request_details(request)
response: Optional[Response] = None
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)
try:
response = await call_next(request)
assert response
return response
finally:
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
await self._log_audit(request, response, duration, request_details)
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) :]
async def _log_audit(
self,
request: Request,
response: Optional[Response],
duration: float,
request_details: Optional[str],
):
try:
http_method = request.scope.get("method", None)
path: Optional[str] = getattr(request.scope.get("route", {}), "path", None)
response_code = str(response.status_code) if response else None
if not settings.audit_http_request(http_method, path, response_code):
return None
ip_address = (
request.client.host
if settings.lnbits_audit_log_ip_address and request.client
else None
)
user_id = request.scope.get("user_id", None)
if settings.is_super_user(user_id):
user_id = "super_user"
component = "core"
if path and not path.startswith("/api"):
component = path.split("/")[1]
elements = [
e for e in ([redirect["ext_id"], *redirect_to, *req_tail_path]) if e != ""
]
data = AuditEntry(
component=component,
ip_address=ip_address,
user_id=user_id,
path=path,
request_type=request.scope.get("type", None),
request_method=http_method,
request_details=request_details,
response_code=response_code,
duration=duration,
)
await self.audit_queue.put(data)
except Exception as ex:
logger.warning(ex)
return "/" + "/".join(elements)
async def _request_details(self, request: Request) -> Optional[str]:
if not settings.audit_http_request_details():
return None
try:
http_method = request.scope.get("method", None)
path = request.scope.get("path", None)
if not settings.audit_http_request(http_method, path):
return None
details: dict = {}
if settings.lnbits_audit_log_path_params:
details["path_params"] = request.path_params
if settings.lnbits_audit_log_query_params:
details["query_params"] = dict(request.query_params)
if settings.lnbits_audit_log_request_body:
_body = await request.body()
details["body"] = _body.decode("utf-8")
details_str = json.dumps(details)
# Make sure the super_user id is not leaked
return details_str.replace(settings.super_user, "super_user")
except Exception as e:
logger.warning(e)
return None
def add_ratelimit_middleware(app: FastAPI):
@@ -214,8 +243,6 @@ def add_ip_block_middleware(app: FastAPI):
)
return await call_next(request)
app.middleware("http")(block_allow_ip_middleware)
def add_first_install_middleware(app: FastAPI):
@app.middleware("http")
@@ -228,5 +255,3 @@ def add_first_install_middleware(app: FastAPI):
):
return RedirectResponse("/first_install")
return await call_next(request)
app.middleware("http")(first_install_middleware)
+288 -18
View File
@@ -4,10 +4,10 @@ import importlib
import importlib.metadata
import inspect
import json
import re
from enum import Enum
from hashlib import sha256
from os import path
from sqlite3 import Row
from time import time
from typing import Any, Optional
@@ -62,26 +62,135 @@ class ExtensionsInstallSettings(LNbitsSettings):
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):
# installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=[])
# 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
lnbits_extensions_redirects: list[Any] = Field(default=[])
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
# list of all extension ids
lnbits_all_extensions_ids: set[Any] = Field(default=[])
lnbits_all_extensions_ids: set[str] = 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(
(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,
)
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]:
path = settings.extension_upgrade_path(ext_id)
return path.split("/")[0] if path else None
def activate_extension_paths(
self,
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 extension_upgrade_hash(self, ext_id: str) -> str:
return settings.lnbits_upgraded_extensions.get(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):
@@ -118,14 +227,27 @@ class ThemesSettings(LNbitsSettings):
class OpsSettings(LNbitsSettings):
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
lnbits_hide_api: bool = Field(default=False)
lnbits_denomination: str = Field(default="sats")
class FeeSettings(LNbitsSettings):
lnbits_reserve_fee_min: int = Field(default=2000)
lnbits_reserve_fee_percent: float = Field(default=1.0)
lnbits_service_fee: float = Field(default=0)
lnbits_service_fee_ignore_internal: bool = Field(default=True)
lnbits_service_fee_max: int = Field(default=0)
lnbits_service_fee_wallet: Optional[str] = Field(default=None)
lnbits_hide_api: bool = Field(default=False)
lnbits_denomination: str = Field(default="sats")
# WARN: this same value must be used for balance check and passed to
# funding_source.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(self, amount_msat: int, internal: bool = False) -> int:
if internal:
return 0
reserve_min = self.lnbits_reserve_fee_min
reserve_percent = self.lnbits_reserve_fee_percent
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
class SecuritySettings(LNbitsSettings):
@@ -194,6 +316,7 @@ class LndRestFundingSource(LNbitsSettings):
lnd_rest_macaroon: Optional[str] = Field(default=None)
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
lnd_rest_route_hints: bool = Field(default=True)
lnd_rest_allow_self_payment: bool = Field(default=False)
lnd_cert: Optional[str] = Field(default=None)
lnd_admin_macaroon: Optional[str] = Field(default=None)
lnd_invoice_macaroon: Optional[str] = Field(default=None)
@@ -258,6 +381,25 @@ class LnTipsFundingSource(LNbitsSettings):
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):
lightning_invoice_expiry: int = Field(default=3600)
@@ -274,11 +416,14 @@ class FundingSourcesSettings(
LnPayFundingSource,
BlinkFundingSource,
AlbyFundingSource,
BoltzFundingSource,
ZBDFundingSource,
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
):
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
@@ -301,10 +446,22 @@ class NodeUISettings(LNbitsSettings):
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password"
nostr_auth_nip98 = "nostr-auth-nip98"
google_auth = "google-auth"
github_auth = "github-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):
auth_token_expire_minutes: int = Field(default=525600)
@@ -315,11 +472,20 @@ class AuthSettings(LNbitsSettings):
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):
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):
google_client_id: str = Field(default="")
google_client_secret: str = Field(default="")
@@ -336,17 +502,106 @@ class KeycloakAuthSettings(LNbitsSettings):
keycloak_client_secret: str = Field(default="")
class AuditSettings(LNbitsSettings):
lnbits_audit_enabled: bool = Field(default=True)
# number of days to keep the audit entry
lnbits_audit_retention_days: int = Field(default=7)
lnbits_audit_log_ip_address: bool = Field(default=False)
lnbits_audit_log_path_params: bool = Field(default=True)
lnbits_audit_log_query_params: bool = Field(default=True)
lnbits_audit_log_request_body: bool = Field(default=False)
# List of paths to be included (regex match). Empty list means all.
lnbits_audit_include_paths: list[str] = Field(default=[".*api/v1/.*"])
# List of paths to be excluded (regex match). Empty list means none.
lnbits_audit_exclude_paths: list[str] = Field(default=["/static"])
# List of HTTP methods to be included. Empty lists means all.
# Options (case-sensitive): GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
lnbits_audit_http_methods: list[str] = Field(
default=["POST", "PUT", "PATCH", "DELETE"]
)
# List of HTTP codes to be included (regex match). Empty lists means all.
lnbits_audit_http_response_codes: list[str] = Field(default=["4.*", "5.*"])
def audit_http_request_details(self) -> bool:
return (
self.lnbits_audit_log_path_params
or self.lnbits_audit_log_query_params
or self.lnbits_audit_log_request_body
)
def audit_http_request(
self,
http_method: Optional[str] = None,
path: Optional[str] = None,
http_response_code: Optional[str] = None,
) -> bool:
if not self.lnbits_audit_enabled:
return False
if len(self.lnbits_audit_http_methods) != 0:
if not http_method:
return False
if http_method not in self.lnbits_audit_http_methods:
return False
if not self._is_http_request_path_auditable(path):
return False
if not self._is_http_response_code_auditable(http_response_code):
return False
return True
def _is_http_request_path_auditable(self, path: Optional[str]):
if len(self.lnbits_audit_exclude_paths) != 0 and path:
for exclude_path in self.lnbits_audit_exclude_paths:
if _re_fullmatch_safe(exclude_path, path):
return False
if len(self.lnbits_audit_include_paths) != 0:
if not path:
return False
for include_path in self.lnbits_audit_include_paths:
if _re_fullmatch_safe(include_path, path):
return True
return False
def _is_http_response_code_auditable(
self, http_response_code: Optional[str]
) -> bool:
if not http_response_code:
# No response code means only request filters should apply
return True
if len(self.lnbits_audit_http_response_codes) == 0:
return True
for response_code in self.lnbits_audit_http_response_codes:
if _re_fullmatch_safe(response_code, http_response_code):
return True
return False
class EditableSettings(
UsersSettings,
ExtensionsSettings,
ThemesSettings,
OpsSettings,
FeeSettings,
SecuritySettings,
FundingSourcesSettings,
LightningSettings,
WebPushSettings,
NodeUISettings,
AuditSettings,
AuthSettings,
NostrAuthSettings,
GoogleAuthSettings,
GitHubAuthSettings,
KeycloakAuthSettings,
@@ -422,7 +677,9 @@ class SuperUserSettings(LNbitsSettings):
lnbits_allowed_funding_sources: list[str] = Field(
default=[
"AlbyWallet",
"BoltzWallet",
"BlinkWallet",
"BreezSdkWallet",
"CoreLightningRestWallet",
"CoreLightningWallet",
"EclairWallet",
@@ -436,6 +693,7 @@ class SuperUserSettings(LNbitsSettings):
"PhoenixdWallet",
"VoidWallet",
"ZBDWallet",
"NWCWallet",
]
)
@@ -466,7 +724,7 @@ class ReadOnlySettings(
PersistenceSettings,
SuperUserSettings,
):
lnbits_admin_ui: bool = Field(default=False)
lnbits_admin_ui: bool = Field(default=True)
@validator(
"lnbits_allowed_funding_sources",
@@ -482,11 +740,6 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
@classmethod
def from_row(cls, row: Row) -> Settings:
data = dict(row)
return cls(**data)
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@@ -501,8 +754,11 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
or user_id == self.super_user
)
def is_super_user(self, user_id: Optional[str] = None) -> bool:
return user_id == self.super_user
def is_admin_user(self, user_id: str) -> bool:
return user_id in self.lnbits_admin_users or user_id == self.super_user
return self.is_super_user(user_id) or user_id in self.lnbits_admin_users
def is_admin_extension(self, ext_id: str) -> bool:
return ext_id in self.lnbits_admin_extensions
@@ -520,6 +776,20 @@ class AdminSettings(EditableSettings):
lnbits_allowed_funding_sources: Optional[list[str]]
class SettingsField(BaseModel):
id: str
value: Optional[Any]
tag: str = "core"
def _re_fullmatch_safe(pattern: str, string: str):
try:
return re.fullmatch(pattern, string) is not None
except Exception as _:
logger.warning(f"Regex error for pattern {pattern}")
return False
def set_cli_settings(**kwargs):
for key, value in kwargs.items():
setattr(settings, key, value)
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
+11 -8
View File
@@ -530,23 +530,26 @@ video {
overflow-wrap: break-word;
}
.qrcode__wrapper canvas {
.qrcode__wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode__wrapper canvas {
width: 100% !important;
max-width: 100%;
max-height: 100%;
height: 100% !important;
max-width: 350px;
}
.qrcode__image {
position: absolute;
max-width: 52px;
width: 15%;
height: 15%;
overflow: hidden;
background: #fff;
left: 50%;
overflow: hidden;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
padding: 0.2rem;
border-radius: 0.2rem;
}
+10 -11
View File
@@ -35,9 +35,9 @@ window.localisation.br = {
reset_defaults_tooltip:
'Apagar todas as configurações e redefinir para os padrões.',
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:
'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 *',
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.',
@@ -58,10 +58,9 @@ window.localisation.br = {
donate: 'Doar',
view_github: 'Ver no GitHub',
voidwallet_active: 'VoidWallet está ativo! Pagamentos desabilitados',
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_max:
'Taxa de serviço: %{amount} % por transação (máx %{max} sats)',
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_max: 'Taxa de serviço: {amount} % por transação (máx {max} sats)',
service_fee_tooltip:
'Taxa de serviço cobrada pelo administrador do servidor LNbits por transação de saída',
toggle_darkmode: 'Alternar modo escuro',
@@ -72,7 +71,7 @@ window.localisation.br = {
lnbits_version: 'Versão do LNbits',
runs_on: 'Executa em',
credit_hint: 'Pressione Enter para creditar a conta',
credit_label: '%{denomination} para creditar',
credit_label: '{denomination} para creditar',
paste: 'Colar',
paste_from_clipboard: 'Cole do clipboard',
paste_request: 'Colar Pedido',
@@ -155,8 +154,8 @@ window.localisation.br = {
expiry: 'Validade',
webhook: 'Webhook',
payment_proof: 'Comprovante de pagamento',
update_available: 'Atualização %{version} disponível!',
latest_update: 'Você está na versão mais recente %{version}.',
update_available: 'Atualização {version} disponível!',
latest_update: 'Você está na versão mais recente {version}.',
notifications: 'Notificações',
no_notifications: 'Sem notificações',
notifications_disabled:
@@ -247,8 +246,8 @@ window.localisation.br = {
look_and_feel: 'Aparência',
language: 'Idioma',
color_scheme: 'Esquema de Cores',
extension_cost: 'Este lançamento requer um pagamento mínimo de %{cost} sats.',
extension_paid_sats: 'Você já pagou %{paid_sats} sats.',
extension_cost: 'Este lançamento requer um pagamento mínimo de {cost} sats.',
extension_paid_sats: 'Você já pagou {paid_sats} sats.',
release_details_error: 'Não é possível obter os detalhes da versão.',
pay_from_wallet: 'Pagar com a Carteira',
show_qr: 'Exibir QR',
+10 -10
View File
@@ -34,9 +34,9 @@ window.localisation.cn = {
reset_defaults: '重置为默认设置',
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
download_backup: '下载数据库备份',
name_your_wallet: '给你的 %{name}钱包起个名字',
name_your_wallet: '给你的 {name}钱包起个名字',
wallet_topup_ok:
'成功创建虚拟资金(%{amount} sats)。付款取决于资金来源的实际资金。',
'成功创建虚拟资金({amount} sats)。付款取决于资金来源的实际资金。',
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
lnbits_description:
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
@@ -56,9 +56,9 @@ window.localisation.cn = {
donate: '捐献',
view_github: '在GitHub上查看',
voidwallet_active: 'VoidWallet 已激活!付款功能已禁用。',
use_with_caution: '请谨慎使用 - %{name}钱包还处于测试版阶段',
service_fee: '服务费:%{amount}% 每笔交易',
service_fee_max: '服务费:%{amount}% 每笔交易(最高 %{max} sats',
use_with_caution: '请谨慎使用 - {name}钱包还处于测试版阶段',
service_fee: '服务费:{amount}% 每笔交易',
service_fee_max: '服务费:{amount}% 每笔交易(最高 {max} sats',
service_fee_tooltip: 'LNbits服务器管理员每笔外发交易收取的服务费',
toggle_darkmode: '切换暗黑模式',
payment_reactions: '支付反应',
@@ -68,7 +68,7 @@ window.localisation.cn = {
lnbits_version: 'LNbits版本',
runs_on: '可运行在',
credit_hint: '按 Enter 键充值账户',
credit_label: '%{denomination} 充值',
credit_label: '{denomination} 充值',
paste: '粘贴',
paste_from_clipboard: '从剪贴板粘贴',
paste_request: '粘贴请求',
@@ -146,8 +146,8 @@ window.localisation.cn = {
expiry: '过期时间',
webhook: 'Webhook',
payment_proof: '付款证明',
update_available: '更新%{version}可用!',
latest_update: '您当前使用的是最新版本%{version}。',
update_available: '更新{version}可用!',
latest_update: '您当前使用的是最新版本{version}。',
notifications: '通知',
no_notifications: '没有通知',
notifications_disabled: 'LNbits状态通知已禁用。',
@@ -235,8 +235,8 @@ window.localisation.cn = {
look_and_feel: '外观和感觉',
language: '语言',
color_scheme: '配色方案',
extension_cost: '此版本需要支付最低 %{cost} sats。',
extension_paid_sats: '您已经支付了%{paid_sats} sats。',
extension_cost: '此版本需要支付最低 {cost} sats。',
extension_paid_sats: '您已经支付了{paid_sats} sats。',
release_details_error: '无法获取发布详情。',
pay_from_wallet: '从钱包支付',
show_qr: '显示QR码',
+10 -10
View File
@@ -34,9 +34,9 @@ window.localisation.cs = {
reset_defaults: 'Obnovit výchozí',
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
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:
'Ú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 *',
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í.',
@@ -58,10 +58,10 @@ window.localisation.cs = {
view_github: 'Zobrazit na GitHubu',
voidwallet_active: 'VoidWallet je aktivní! Platby zakázány',
use_with_caution:
'POUŽÍVEJTE S OBEZŘETNOSTÍ - %{name} peněženka je stále v BETĚ',
service_fee: 'Servisný poplatek: %{amount} % za transakci',
'POUŽÍVEJTE S OBEZŘETNOSTÍ - {name} peněženka je stále v BETĚ',
service_fee: 'Servisný poplatek: {amount} % za transakci',
service_fee_max:
'Servisný poplatek: %{amount} % za transakci (max %{max} satoshi)',
'Servisný poplatek: {amount} % za transakci (max {max} satoshi)',
service_fee_tooltip:
'Servisní poplatek účtovaný správcem LNbits serveru za odchozí transakci',
toggle_darkmode: 'Přepnout tmavý režim',
@@ -72,7 +72,7 @@ window.localisation.cs = {
lnbits_version: 'Verze LNbits',
runs_on: 'Běží na',
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_from_clipboard: 'Vložit ze schránky',
paste_request: 'Vložit požadavek',
@@ -153,8 +153,8 @@ window.localisation.cs = {
expiry: 'Expirace',
webhook: 'Webhook',
payment_proof: 'Důkaz platby',
update_available: 'Dostupná aktualizace %{version}!',
latest_update: 'Máte nejnovější verzi %{version}.',
update_available: 'Dostupná aktualizace {version}!',
latest_update: 'Máte nejnovější verzi {version}.',
notifications: 'Notifikace',
no_notifications: 'Žádné notifikace',
notifications_disabled: 'Notifikace stavu LNbits jsou zakázány.',
@@ -244,8 +244,8 @@ window.localisation.cs = {
look_and_feel: 'Vzhled a chování',
language: 'Jazyk',
color_scheme: 'Barevné schéma',
extension_cost: 'Toto vydání vyžaduje minimální platbu %{cost} satoshi.',
extension_paid_sats: 'Již jste zaplatili %{paid_sats} sats.',
extension_cost: 'Toto vydání vyžaduje minimální platbu {cost} satoshi.',
extension_paid_sats: 'Již jste zaplatili {paid_sats} sats.',
release_details_error: 'Nelze získat podrobnosti o vydání.',
pay_from_wallet: 'Platit z peněženky',
show_qr: 'Zobrazit QR',

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